mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 19:21:32 -07:00
Compare commits
14 Commits
055bd76718
...
f28821a8cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
f28821a8cb
|
|||
|
a75c83e25e
|
|||
|
e51bb74e1b
|
|||
|
5bccb55afc
|
|||
|
ea8071f676
|
|||
|
ac93a5bd2e
|
|||
|
ba48db6255
|
|||
|
992856ac5e
|
|||
|
de19c40118
|
|||
|
a05a698774
|
|||
|
7a08382c23
|
|||
|
2c01baafc9
|
|||
|
76b546c8f6
|
|||
|
c9df5b7624
|
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: TASK-294
|
||||
title: Fix annotated subtitle tokens to honor subtitleStyle
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-25 23:04'
|
||||
updated_date: '2026-04-25 23:07'
|
||||
labels:
|
||||
- subtitles
|
||||
- renderer
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Annotated token spans should inherit the configured subtitleStyle typography and only use annotation metadata to change token color.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tokenized/annotated subtitles preserve configured base subtitle typography such as font family, size, weight, line height, letter spacing, text rendering, and text shadow.
|
||||
- [x] #2 Known/N+1/JLPT/frequency/name-match annotations affect token color only, plus existing token metadata/hover affordances.
|
||||
- [x] #3 A renderer regression test covers annotated token rendering with custom subtitleStyle.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Updated renderer subtitle annotation CSS so known/N+1/name/JLPT/frequency classes no longer override typography with token-specific shadows, underlines, padding, or hover font-weight. Added regression coverage using the user's custom subtitleStyle shape to verify annotated token spans inherit base typography and annotation CSS changes token color only. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: TASK-295
|
||||
title: Add primary subtitle visibility keybinding
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-04-25 23:09'
|
||||
updated_date: '2026-04-25 23:45'
|
||||
labels:
|
||||
- renderer
|
||||
- keybindings
|
||||
- subtitles
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a `v` keybinding that overrides mpv's default `v` subtitle visibility toggle and instead toggles SubMiner's primary subtitle bar visibility on and off. Secondary subtitle hover behavior is out of scope.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Pressing `v` toggles the primary subtitle bar from visible to hidden.
|
||||
- [x] #2 Pressing `v` again restores the primary subtitle bar visibility.
|
||||
- [x] #3 The keybinding does not add or change secondary subtitle hover behavior.
|
||||
- [x] #4 Relevant automated coverage verifies the toggle behavior.
|
||||
- [x] #5 Pressing `v` in the mpv/plugin keybinding path also toggles the primary subtitle bar visibility instead of mpv native subtitle visibility.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect existing renderer keybinding and subtitle bar visibility code, including current local edits in touched files.
|
||||
2. Add a focused failing test for `v` toggling primary subtitle bar visibility without changing secondary hover behavior.
|
||||
3. Implement the minimal renderer/keybinding change.
|
||||
4. Run targeted tests and update acceptance criteria/final notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented renderer-local `KeyV` handling before session/mpv binding dispatch so mpv `sub-visibility` is not touched. Visibility state is stored in renderer state and applied via `primary-sub-hidden` class on the primary subtitle container.
|
||||
|
||||
Scope updated after user clarified the toggle must work when focus is in mpv as well as in the overlay renderer. Added a forced mpv plugin binding for `v` that runs `--toggle-primary-subtitle-bar`, then broadcasts a renderer IPC toggle event and reuses the same primary subtitle bar toggle path.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Summary:
|
||||
- Added a renderer-local `v` key handler that toggles primary subtitle bar visibility by adding/removing `primary-sub-hidden` on the primary subtitle container.
|
||||
- Added renderer state for the toggle so repeated presses restore the bar without issuing mpv `sub-visibility` commands.
|
||||
- Added a forced mpv plugin `v` binding that invokes `--toggle-primary-subtitle-bar` and broadcasts the same renderer toggle event.
|
||||
- Added CSS for the hidden primary subtitle bar state and regression coverage for both overlay and mpv/plugin entry points.
|
||||
|
||||
Tests:
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts --test-name-pattern "primary subtitle visibility key"`
|
||||
- `bun test src/cli/args.test.ts --test-name-pattern "session action"`
|
||||
- `bun test src/core/services/cli-command.test.ts --test-name-pattern "visibility and utility"`
|
||||
- `bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/cli-command-context-deps.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-context-factory.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/first-run-setup-service.test.ts`
|
||||
- `lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-lua-compat.lua`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-296
|
||||
title: Suppress crash notification when closing launcher-managed video
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-25 23:12'
|
||||
updated_date: '2026-04-26 02:44'
|
||||
labels:
|
||||
- bug
|
||||
- launcher
|
||||
- mpv
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate and fix regression where closing a running mpv video causes SubMiner/Electron service crash notification (`<html><tt>/SubMiner</tt> has encountered a fatal error and was closed.</html>`). Not present on origin/main/v0.12.0 path.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Closing a launcher-managed video stops the overlay/app without desktop crash notification.
|
||||
- [x] #2 Regression test covers the shutdown path that caused the notification.
|
||||
- [x] #3 Relevant launcher/app tests pass.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Confirm notification source from local crash metadata and mpv/app logs.
|
||||
2. Add regression coverage for mpv quit/shutdown lifecycle helper spawning.
|
||||
3. Update mpv Lua lifecycle to avoid Electron helper subprocesses during quit/shutdown while preserving normal end-file hide behavior.
|
||||
4. Run plugin tests and changelog lint.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Crash records in ~/.cache/drkonqi show SIGBUS in Electron NetworkService utility process for SubMiner AppImage. mpv log shows shutdown starts two `/home/sudacode/.local/bin/SubMiner.AppImage --hide-visible-overlay` subprocesses and kills them during close. Root cause is mpv plugin spawning Electron control helpers during quit/shutdown.
|
||||
|
||||
Follow-up after retest: installed plugin matched source patch and no close-time hide command was spawned. New mpv log shows the initial `/home/sudacode/.local/bin/SubMiner.AppImage --start ...` subprocess remains owned by mpv for the whole playback and is killed when mpv quits. New DrKonqi crash at 2026-04-25 16:44 again shows SIGBUS in Electron NetworkService from that AppImage mount. Need detach the long-lived plugin-launched `--start` app process from mpv.
|
||||
|
||||
Second fix: plugin-launched `--start` now includes `--background`, using SubMiner's existing background relaunch path so mpv owns only a short-lived starter process rather than the long-running Electron app. Ran `make install-plugin` so ~/.config/mpv/scripts/subminer now contains both lifecycle and background-start fixes. Full `scripts/test-plugin-start-gate.lua` is currently blocked by an unrelated dirty-worktree primary subtitle bar binding test from TASK-297.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Changed the mpv Lua lifecycle so `shutdown` no longer spawns a `--hide-visible-overlay` helper, and `end-file` skips that helper when mpv reports `reason = "quit"`. Also changed plugin-launched `--start` to include `--background`, so mpv owns only SubMiner's short background launcher process instead of the long-running Electron/AppImage process. This addresses both observed crash sources: close-time helper commands and mpv killing the main SubMiner child process at quit. Installed the updated plugin into `~/.config/mpv/scripts/subminer` with `make install-plugin`, and the user confirmed the latest close no longer produced the notification.
|
||||
|
||||
Tests: `lua scripts/test-plugin-start-gate.lua` initially proved the shutdown regression failed before the lifecycle fix; full start-gate is currently affected by other dirty work in this file. Passing checks for this commit: `lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-binary-windows.lua`; `bun run changelog:lint`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: TASK-297
|
||||
title: Fix subtitle annotation color priority after typography cleanup
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-25 23:44'
|
||||
updated_date: '2026-04-25 23:46'
|
||||
labels:
|
||||
- subtitles
|
||||
- renderer
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Known-word and frequency subtitle token colors should keep their configured priority after annotation CSS stopped using JLPT underlines.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Known-word token color takes priority over JLPT and frequency color classes.
|
||||
- [x] #2 Frequency single-mode token color takes priority over JLPT color classes when frequency annotation is active.
|
||||
- [x] #3 Regression coverage verifies CSS selectors do not allow JLPT color rules to override higher-priority annotation colors.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Scoped JLPT token color selectors so they only apply when no higher-priority known-word, N+1, name-match, or frequency class is present. This keeps known words green and frequency single-mode tokens using the single frequency color instead of being visually overridden by JLPT colors. Added CSS regression assertions for this priority behavior. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-298
|
||||
title: Exclude kana grammar-helper merges like ことに from subtitle annotations
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-26 00:08'
|
||||
updated_date: '2026-04-26 00:15'
|
||||
labels:
|
||||
- tokenizer
|
||||
- annotations
|
||||
- bug
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate and fix subtitle tokenizer annotation behavior where all-hiragana grammar-helper merged tokens such as `ことに` can be marked as N+1. Current likely path: Yomitan emits `ことに` with headword `こと`; MeCab enrichment supplies content-led POS (`名詞|助詞`, likely `非自立|格助詞`); shared subtitle annotation filter does not exclude this family unless it matches narrower rules such as `これで` or explanatory endings.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `ことに`-style kana grammar-helper merges are not marked known, N+1, JLPT, or frequency-highlighted when their MeCab metadata indicates a non-independent noun plus helper particle.
|
||||
- [x] #2 Regression coverage demonstrates the reported subtitle phrase does not mark `ことに` as N+1 while preserving annotation for real lexical content tokens.
|
||||
- [x] #3 Existing tokenizer annotation tests pass.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
Approved approach (user: "let's do it"):
|
||||
1. Add a regression test for the reported `ことに` case using Yomitan token `ことに` -> headword `こと` and MeCab metadata `名詞|助詞` / `非自立|格助詞`; assert all annotation fields are stripped while nearby lexical content can still be N+1.
|
||||
2. Verify the new test fails before production changes.
|
||||
3. Update the shared subtitle annotation filter to exclude conservative kana-only grammar-helper merges: merged surface differs from headword, surface is kana-only, first POS component is `名詞`, first POS2 component is `非自立`, and remaining POS components are grammar helpers (`助詞`/`助動詞`).
|
||||
4. Run targeted tokenizer/annotation tests and update the task acceptance criteria/final notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Red test initially passed with headword `こと` because `こと` is already in `JLPT_EXCLUDED_TERMS` and the shared subtitle annotation filter checks that set. Updated regression to the live-risk shape `surface=ことに`, `headword=事`, with MeCab POS `名詞|助詞` / `非自立|格助詞`; this failed before the filter change and passed after.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented a conservative shared subtitle annotation filter for kana-only non-independent noun helper merges. Tokens such as `ことに` with a kanji dictionary headword like `事` are now stripped of known-word, N+1, JLPT, and frequency metadata when MeCab shows the first component as `名詞/非自立` and trailing components as grammar helpers.
|
||||
|
||||
Added unit coverage in `src/core/services/tokenizer/annotation-stage.test.ts` and an integration-style tokenizer regression for the reported phrase shape in `src/core/services/tokenizer.test.ts`, verifying `ことに` stays plain while a real lexical token can still become the N+1 target.
|
||||
|
||||
Validation: `bun test src/core/services/tokenizer/annotation-stage.test.ts`; `bun test src/core/services/tokenizer.test.ts`; `bun run test:fast`; `bun run changelog:lint`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: TASK-299
|
||||
title: Force audio replacement during manual subtitle mining
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-04-26 00:10'
|
||||
updated_date: '2026-04-26 02:42'
|
||||
labels:
|
||||
- anki
|
||||
- mining
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings.
|
||||
- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings.
|
||||
- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings.
|
||||
- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate.
|
||||
2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled.
|
||||
3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement.
|
||||
4. Run the focused mining test and update task acceptance criteria/final notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths.
|
||||
|
||||
Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Summary:
|
||||
- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio.
|
||||
- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled.
|
||||
- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment.
|
||||
|
||||
Verification:
|
||||
- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts
|
||||
- bun run tsc --noEmit
|
||||
- bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts
|
||||
- bun run changelog:lint
|
||||
- bun run docs:test
|
||||
- bun run docs:build
|
||||
- git diff --check
|
||||
|
||||
Blocked gate:
|
||||
- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: TASK-300
|
||||
title: Fix transparent subtitle hover background config
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-26 03:23'
|
||||
updated_date: '2026-04-26 03:26'
|
||||
labels:
|
||||
- bug
|
||||
- overlay
|
||||
- config
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
User reports setting subtitleStyle.hoverTokenBackgroundColor to transparent still renders default hover background in overlay subtitles.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Transparent hoverTokenBackgroundColor is accepted by config resolution.
|
||||
- [x] #2 Renderer applies transparent hover token background instead of falling back to default.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Reproduce config alias behavior with a failing config test.
|
||||
2. Map subtitleStyle.hoverBackground to hoverTokenBackgroundColor in config resolution while keeping canonical key precedence.
|
||||
3. Add renderer regression for transparent hover token background CSS variable.
|
||||
4. Update docs and changelog fragment; run focused verification.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Local user config used subtitleStyle.hoverBackground, which was ignored because only subtitleStyle.hoverTokenBackgroundColor was recognized. Canonical key still takes precedence when both are present.
|
||||
|
||||
Verification passed: bun run test:config:src; bun test src/renderer/subtitle-render.test.ts; bun run changelog:lint; bun run docs:test; bun run docs:build.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented config compatibility for transparent hover token backgrounds. `subtitleStyle.hoverBackground` now maps to the canonical `subtitleStyle.hoverTokenBackgroundColor` during resolution, preserving canonical key precedence. Added regression coverage for the alias and renderer handling of `transparent`, documented the alias, and added a changelog fragment.
|
||||
|
||||
Verification: `bun run test:config:src`; `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run docs:test`; `bun run docs:build`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: TASK-301
|
||||
title: Fix launcher-managed video close leaving background app alive
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-04-26 03:29'
|
||||
updated_date: '2026-04-26 03:44'
|
||||
labels:
|
||||
- bug
|
||||
- launcher
|
||||
- mpv
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Launcher/plugin-managed video playback should not leave the SubMiner background app or tray icon running after the video closes unless the user explicitly launched SubMiner in background mode via --background or by starting with no app arguments. This is a regression after crash-avoidance work that added background startup for launcher-managed playback.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Closing a launcher-managed video exits the launcher-started SubMiner app/tray instead of leaving it alive.
|
||||
- [x] #2 Explicit background launches still keep SubMiner alive after windows close.
|
||||
- [x] #3 No-argument app startup behavior remains unchanged.
|
||||
- [x] #4 Regression coverage exercises the launcher-managed playback shutdown lifecycle.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression coverage first: plugin auto-start should tag launcher-managed playback, and app mpv shutdown handling should quit only when started in that managed playback mode.
|
||||
2. Add a narrow CLI flag/state field for launcher-managed playback, separate from explicit persistent background mode.
|
||||
3. Have plugin pass the new flag with its background start command.
|
||||
4. On mpv shutdown/disconnect, request app quit only when managed playback mode is active; preserve explicit --background and no-arg startup persistence.
|
||||
5. Run focused plugin/app tests, then relevant launcher/core gates if feasible.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented managed playback shutdown by adding a `--managed-playback` app flag that the mpv plugin passes only for launcher-managed starts. The main mpv shutdown path now quits the app when initial args indicate managed playback, while explicit background/no-arg startup remains persistent. Added plugin start-gate and mpv protocol regression coverage.
|
||||
|
||||
Implemented managed playback lifecycle: mpv plugin auto-start passes --background --managed-playback; app quits on mpv shutdown only when initial args include managedPlayback. Explicit --background and no-arg startup remain persistent. Installed updated mpv plugin to ~/.config/mpv/scripts/subminer via make install-plugin.
|
||||
|
||||
Retest showed tray still remained. Root cause: relying on mpv's JSON IPC shutdown event was insufficient; the app may only see the socket close. Added managed-playback quit on MpvIpcClient onClose before reconnect scheduling, with regression coverage.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Launcher-managed playback now starts SubMiner with an internal --managed-playback marker alongside --background. The app requests quit either when mpv sends shutdown or when the mpv IPC socket closes, but only for managed playback mode; explicit background/no-arg startup remains persistent. Added CLI, mpv protocol, mpv socket-close, and plugin regression coverage plus a launcher changelog fragment. Rebuilt the app/launcher and confirmed focused checks, typecheck, build, plugin tests, dist smoke, and formatting.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-302
|
||||
title: Add visible hover affordance for annotated subtitle tokens
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-26 03:39'
|
||||
updated_date: '2026-04-26 03:40'
|
||||
labels:
|
||||
- overlay
|
||||
- subtitle
|
||||
- ux
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Annotated subtitle tokens keep annotation colors on hover, but with transparent hover backgrounds there is no visible hover indication. Add a subtle affordance that preserves annotation color semantics.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Annotated token hover keeps annotation color instead of switching to hover text color.
|
||||
- [x] #2 Annotated token hover has a visible indication when hover background is transparent.
|
||||
- [x] #3 Regression tests cover the hover CSS contract.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing CSS contract test for annotated token hover affordance.
|
||||
2. Add brightness/saturation filter to annotated token hover CSS without changing annotation color.
|
||||
3. Add changelog fragment and run focused verification.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented option 1 from approved design: annotated subtitle word hover keeps annotation color and adds `filter: brightness(1.18) saturate(1.08)` for visible affordance when the hover background is transparent.
|
||||
|
||||
Verification passed: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added a visible hover affordance for annotated subtitle tokens without changing annotation color semantics. Annotated word hover now applies a small brightness/saturation lift while retaining the existing background behavior, so transparent hover backgrounds still show feedback.
|
||||
|
||||
Regression coverage updated in `src/renderer/subtitle-render.test.ts` to assert the hover filter is present and that hover color overrides are still absent for annotated tokens. Added changelog fragment `changes/302-annotated-hover-affordance.md`.
|
||||
|
||||
Verification: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: TASK-303
|
||||
title: Update tray menu help action
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-04-26 03:54'
|
||||
updated_date: '2026-04-26 04:12'
|
||||
labels:
|
||||
- tray
|
||||
- overlay
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the tray menu's direct visible-overlay open action with an action that opens the existing in-session help modal. The tray should no longer expose an "Open Overlay" menu item; users should be able to open help from the tray instead.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tray menu no longer includes an "Open Overlay" option.
|
||||
- [x] #2 Tray menu includes an option to open the session help modal.
|
||||
- [x] #3 Selecting the new tray help option initializes overlay runtime if needed and invokes the existing session help modal path.
|
||||
- [x] #4 Focused regression tests cover the menu label and action wiring.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add focused regression coverage for tray menu template labels and main-process action wiring: assert Open Overlay is absent, Open Help is present, and clicking help initializes overlay runtime if needed before opening the existing session help modal path.
|
||||
2. Update tray runtime action types/template to replace openOverlay with openSessionHelp.
|
||||
3. Update tray main action builder dependencies to call the existing openSessionHelpModal function after overlay runtime initialization.
|
||||
4. Run targeted tray tests, then broader relevant fast tests if needed.
|
||||
5. Check acceptance criteria and finalize backlog notes.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented tray menu replacement via existing session help overlay path. Verification passed: targeted tray tests (`bun test src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts`), SubMiner verifier lanes `runtime-compat` and `docs`, and `bun run changelog:lint`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Replaced the tray menu's `Open Overlay` item with `Open Help`, wired it to initialize overlay runtime when needed, and then open the existing session help modal path. Updated tray runtime/main-deps/action tests to assert the old label is absent, the new label is present, and the new action calls the help modal. Added changelog fragment `changes/303-tray-help-menu.md`.
|
||||
|
||||
Verification:
|
||||
- `bun test src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts`
|
||||
- `bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane runtime-compat --lane docs src/main/runtime/tray-runtime.ts src/main/runtime/tray-main-actions.ts src/main/runtime/tray-main-deps.ts src/main.ts changes/303-tray-help-menu.md`
|
||||
- `bun run changelog:lint`
|
||||
|
||||
Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260425-211156-9fkdDf/`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar without changing mpv native subtitle visibility.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mpv
|
||||
|
||||
- Stopped mpv from owning long-running SubMiner AppImage subprocesses during playback shutdown, preventing desktop crash notifications when closing video.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed annotated subtitle token colors so `subtitleStyle` typography is preserved and higher-priority known-word/frequency colors are not overridden by JLPT colors.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: tokenizer
|
||||
|
||||
- Stopped kana-only grammar-helper merges such as `ことに` from receiving subtitle annotation metadata like N+1, JLPT, known-word, or frequency highlighting.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`, so setting it to `transparent` removes hover token backgrounds.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Launcher-managed playback now exits the background SubMiner app when the video closes, while explicit background launches stay persistent.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Added a subtle brightness lift for annotated subtitle token hover states so transparent hover backgrounds still show a visible hover affordance.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: tray
|
||||
|
||||
- Tray: Replaced the Open Overlay tray menu item with Open Help, which opens the session help modal.
|
||||
@@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
||||
}
|
||||
```
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field.
|
||||
|
||||
## AI Translation
|
||||
|
||||
SubMiner can auto-translate the mined sentence and fill the translation field.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
**Changed**
|
||||
- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
|
||||
- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
|
||||
- Overlay: Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar instead of mpv's native primary subtitle visibility.
|
||||
- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
|
||||
- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
|
||||
- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
|
||||
|
||||
+56
-56
@@ -310,7 +310,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
|
||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
|
||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
@@ -560,7 +560,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) |
|
||||
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
@@ -691,7 +691,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but
|
||||
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
||||
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
||||
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
||||
| `Ctrl+Alt+A` | Open character dictionary AniList selector |
|
||||
| `Ctrl+Alt+A` | Open character dictionary AniList selector |
|
||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||
|
||||
@@ -857,59 +857,59 @@ This example is intentionally compact. The option table below documents availabl
|
||||
|
||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
|
||||
@@ -100,6 +100,8 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
|
||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
||||
|
||||
Manual clipboard updates always replace generated audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card.
|
||||
|
||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
|
||||
@@ -41,11 +41,14 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `y-o` | Open settings window |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check status |
|
||||
| `y-k` | Skip intro (AniSkip) |
|
||||
|
||||
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
|
||||
|
||||
## Menu
|
||||
|
||||
Press `y-y` to open an interactive menu in mpv's OSD:
|
||||
|
||||
@@ -38,6 +38,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| Shortcut | Action |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `V` | Toggle primary subtitle bar visibility |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
@@ -100,11 +101,14 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
||||
|
||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
||||
|
||||
## Drag-and-Drop
|
||||
|
||||
@@ -114,6 +114,7 @@ SubMiner.AppImage --stop # Stop overlay
|
||||
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||
SubMiner.AppImage --start --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
@@ -327,6 +328,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
|
||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||
|
||||
Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle.
|
||||
|
||||
`Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||
|
||||
@@ -219,12 +219,17 @@ export function parseCliPrograms(
|
||||
.option('--select <id>', 'Pin an AniList media ID for the target series')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((target: string | undefined, options: Record<string, unknown>) => {
|
||||
const selectValue = typeof options.select === 'string' ? options.select.trim() : '';
|
||||
const hasSelect = selectValue.length > 0;
|
||||
if (options.candidates === true && hasSelect) {
|
||||
throw new Error('Dictionary --candidates and --select cannot be combined.');
|
||||
}
|
||||
dictionaryTriggered = true;
|
||||
dictionaryTarget = target ?? null;
|
||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
dictionaryCandidates = options.candidates === true;
|
||||
dictionarySelect = typeof options.select === 'string';
|
||||
dictionaryAnilistId = typeof options.select === 'string' ? options.select : null;
|
||||
dictionarySelect = hasSelect;
|
||||
dictionaryAnilistId = hasSelect ? selectValue : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
|
||||
@@ -477,7 +477,7 @@ test('dictionary command forwards manual AniList selection modes to app command
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" >> "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
|
||||
@@ -110,6 +110,14 @@ test('parseArgs maps dictionary candidate lookup and manual selection', () => {
|
||||
assert.equal(selectParsed.dictionaryTarget, process.cwd());
|
||||
});
|
||||
|
||||
test('parseArgs rejects conflicting dictionary candidate and selection modes', () => {
|
||||
const exit = withProcessExitIntercept(() => {
|
||||
parseArgs(['dictionary', '--candidates', '--select', '21355', '.'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(exit.code, 1);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats command and log-level override', () => {
|
||||
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -73,10 +73,6 @@ function M.create(ctx)
|
||||
aniskip.clear_aniskip_state()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
if state.overlay_running then
|
||||
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay")
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end
|
||||
|
||||
local function register_lifecycle_hooks()
|
||||
@@ -85,10 +81,11 @@ function M.create(ctx)
|
||||
mp.register_event("file-loaded", function()
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.register_event("end-file", function()
|
||||
mp.register_event("end-file", function(event)
|
||||
process.disarm_auto_play_ready_gate()
|
||||
hover.clear_hover_overlay()
|
||||
if state.overlay_running then
|
||||
local reason = type(event) == "table" and event.reason or nil
|
||||
if state.overlay_running and reason ~= "quit" then
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -186,6 +186,9 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if action == "start" then
|
||||
table.insert(args, "--background")
|
||||
table.insert(args, "--managed-playback")
|
||||
|
||||
local backend = resolve_backend(overrides.backend)
|
||||
if backend and backend ~= "" then
|
||||
table.insert(args, "--backend")
|
||||
@@ -462,6 +465,21 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function toggle_primary_subtitle_bar()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
show_osd("Error: binary not found")
|
||||
return
|
||||
end
|
||||
|
||||
run_control_command_async("toggle-primary-subtitle-bar", nil, function(ok)
|
||||
if not ok then
|
||||
subminer_log("warn", "process", "Primary subtitle bar toggle command failed")
|
||||
show_osd("Primary subtitle toggle failed")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function open_options()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -552,6 +570,7 @@ function M.create(ctx)
|
||||
stop_overlay = stop_overlay,
|
||||
hide_visible_overlay = hide_visible_overlay,
|
||||
toggle_overlay = toggle_overlay,
|
||||
toggle_primary_subtitle_bar = toggle_primary_subtitle_bar,
|
||||
open_options = open_options,
|
||||
restart_overlay = restart_overlay,
|
||||
check_status = check_status,
|
||||
|
||||
@@ -80,6 +80,9 @@ function M.create(ctx)
|
||||
mp.add_key_binding("y-t", "subminer-toggle", function()
|
||||
process.toggle_overlay()
|
||||
end)
|
||||
mp.add_forced_key_binding("v", "subminer-toggle-primary-subtitle-bar", function()
|
||||
process.toggle_primary_subtitle_bar()
|
||||
end)
|
||||
mp.add_key_binding("y-y", "subminer-menu", show_menu)
|
||||
mp.add_key_binding("y-o", "subminer-options", function()
|
||||
process.open_options()
|
||||
|
||||
@@ -151,6 +151,14 @@ local function run_plugin_scenario(config)
|
||||
fn = fn,
|
||||
}
|
||||
end
|
||||
function mp.add_forced_key_binding(keys, name, fn)
|
||||
recorded.key_bindings[#recorded.key_bindings + 1] = {
|
||||
keys = keys,
|
||||
name = name,
|
||||
fn = fn,
|
||||
forced = true,
|
||||
}
|
||||
end
|
||||
function mp.register_event(name, fn)
|
||||
if recorded.events[name] == nil then
|
||||
recorded.events[name] = {}
|
||||
@@ -491,10 +499,10 @@ local function count_property_set(property_sets, name, value)
|
||||
return count
|
||||
end
|
||||
|
||||
local function fire_event(recorded, name)
|
||||
local function fire_event(recorded, name, ...)
|
||||
local listeners = recorded.events[name] or {}
|
||||
for _, listener in ipairs(listeners) do
|
||||
listener()
|
||||
listener(...)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -537,6 +545,39 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for primary subtitle bar binding scenario: " .. tostring(err))
|
||||
local binding = nil
|
||||
for _, candidate in ipairs(recorded.key_bindings) do
|
||||
if candidate.name == "subminer-toggle-primary-subtitle-bar" then
|
||||
binding = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(binding ~= nil, "primary subtitle bar v binding should be registered")
|
||||
assert_true(binding.keys == "v", "primary subtitle bar binding should use bare v")
|
||||
assert_true(binding.forced == true, "primary subtitle bar binding should override mpv's built-in v binding")
|
||||
binding.fn()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--toggle-primary-subtitle-bar") == 1,
|
||||
"primary subtitle bar binding should issue primary subtitle toggle command"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
|
||||
"primary subtitle bar binding should not toggle the whole visible overlay"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -727,6 +768,11 @@ do
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||
assert_true(call_has_arg(start_call, "--background"), "auto-start should launch SubMiner in background mode")
|
||||
assert_true(
|
||||
call_has_arg(start_call, "--managed-playback"),
|
||||
"auto-start should mark SubMiner as launcher-managed playback"
|
||||
)
|
||||
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
|
||||
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
|
||||
assert_true(
|
||||
@@ -1013,11 +1059,20 @@ do
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
fire_event(recorded, "end-file", { reason = "quit" })
|
||||
assert_true(
|
||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
||||
"mpv quit end-file should not spawn hide-visible-overlay helper commands"
|
||||
)
|
||||
fire_event(recorded, "shutdown")
|
||||
assert_true(
|
||||
find_control_call(recorded.async_calls, "--stop") == nil,
|
||||
"mpv shutdown should not stop the background SubMiner process"
|
||||
)
|
||||
assert_true(
|
||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
||||
"mpv shutdown should not spawn hide-visible-overlay helper commands"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
service: CardCreationService;
|
||||
updatedFields: Record<string, string>[];
|
||||
mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>;
|
||||
storedMedia: string[];
|
||||
} {
|
||||
const updatedFields: Record<string, string>[] = [];
|
||||
const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'ExpressionAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: false,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: false,
|
||||
overwriteImage: false,
|
||||
},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () =>
|
||||
({
|
||||
findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null),
|
||||
}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: (fields) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]),
|
||||
),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (existing, newValue, overwrite) => {
|
||||
mergeCalls.push({ existing, newValue, overwrite });
|
||||
return overwrite || !existing.trim() ? newValue : existing;
|
||||
},
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
service: new CardCreationService(deps),
|
||||
updatedFields,
|
||||
mergeCalls,
|
||||
storedMedia,
|
||||
};
|
||||
}
|
||||
|
||||
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
||||
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||
assert.deepEqual(
|
||||
mergeCalls.map((call) => call.overwrite),
|
||||
[true, true],
|
||||
);
|
||||
});
|
||||
@@ -219,6 +219,10 @@ export class CardCreationService {
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
const expressionAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
|
||||
);
|
||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
||||
|
||||
const sentence = blocks.join(' ');
|
||||
@@ -248,13 +252,22 @@ export class CardCreationService {
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
if (sentenceAudioField) {
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||
existingAudio,
|
||||
`[sound:${audioFilename}]`,
|
||||
this.deps.getConfig().behavior?.overwriteAudio !== false,
|
||||
if (sentenceAudioField || expressionAudioField) {
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
const audioFields = new Set(
|
||||
[sentenceAudioField, expressionAudioField].filter(
|
||||
(fieldName): fieldName is string => Boolean(fieldName),
|
||||
),
|
||||
);
|
||||
for (const audioField of audioFields) {
|
||||
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||
// Manual clipboard updates intentionally replace old captured audio.
|
||||
updatedFields[audioField] = this.deps.mergeFieldValue(
|
||||
existingAudio,
|
||||
audioValue,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
updatePerformed = true;
|
||||
|
||||
+13
-1
@@ -79,6 +79,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--open-jimaku',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
'--toggle-primary-subtitle-bar',
|
||||
'--replay-current-subtitle',
|
||||
'--play-next-subtitle',
|
||||
'--shift-sub-delay-prev-line',
|
||||
@@ -94,6 +95,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(args.openJimaku, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
assert.equal(args.replayCurrentSubtitle, true);
|
||||
assert.equal(args.playNextSubtitle, true);
|
||||
assert.equal(args.shiftSubDelayPrevLine, true);
|
||||
@@ -212,7 +214,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||
|
||||
const dictionaryCandidates = parseArgs(['--dictionary-candidates', '--dictionary-target', '/tmp/a.mkv']);
|
||||
const dictionaryCandidates = parseArgs([
|
||||
'--dictionary-candidates',
|
||||
'--dictionary-target',
|
||||
'/tmp/a.mkv',
|
||||
]);
|
||||
assert.equal(dictionaryCandidates.dictionaryCandidates, true);
|
||||
assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv');
|
||||
assert.equal(hasExplicitCommand(dictionaryCandidates), true);
|
||||
@@ -332,6 +338,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(background), true);
|
||||
assert.equal(shouldStartApp(background), true);
|
||||
|
||||
const managedPlayback = parseArgs(['--background', '--managed-playback']);
|
||||
assert.equal(managedPlayback.background, true);
|
||||
assert.equal(managedPlayback.managedPlayback, true);
|
||||
assert.equal(hasExplicitCommand(managedPlayback), true);
|
||||
assert.equal(shouldStartApp(managedPlayback), true);
|
||||
|
||||
const setup = parseArgs(['--setup']);
|
||||
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface CliArgs {
|
||||
background: boolean;
|
||||
managedPlayback: boolean;
|
||||
start: boolean;
|
||||
launchMpv: boolean;
|
||||
launchMpvTargets: string[];
|
||||
@@ -8,6 +9,7 @@ export interface CliArgs {
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
settings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
@@ -98,6 +100,7 @@ export type CliCommandSource = 'initial' | 'second-instance';
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
@@ -106,6 +109,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
@@ -199,6 +203,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
if (!arg || !arg.startsWith('--')) continue;
|
||||
|
||||
if (arg === '--background') args.background = true;
|
||||
else if (arg === '--managed-playback') args.managedPlayback = true;
|
||||
else if (arg === '--start') args.start = true;
|
||||
else if (arg.startsWith('--youtube-play=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
@@ -219,6 +224,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--stop') args.stop = true;
|
||||
else if (arg === '--toggle') args.toggle = true;
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
@@ -456,6 +462,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
@@ -526,6 +533,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.stop &&
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
@@ -591,6 +599,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.launchMpv ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
@@ -644,6 +653,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.stop &&
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
@@ -707,6 +717,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
return (
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
args.showVisibleOverlay ||
|
||||
|
||||
@@ -19,6 +19,7 @@ ${B}Session${R}
|
||||
|
||||
${B}Overlay${R}
|
||||
--toggle-visible-overlay Toggle subtitle overlay
|
||||
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
|
||||
@@ -437,6 +437,46 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverBackground as a hoverTokenBackgroundColor alias', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"hoverBackground": "transparent"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenBackgroundColor null as invalid instead of missing', () => {
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"hoverTokenBackgroundColor": null
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
|
||||
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -260,20 +260,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = asString(
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
||||
);
|
||||
const subtitleStyleSource = src.subtitleStyle as {
|
||||
hoverBackground?: unknown;
|
||||
hoverTokenBackgroundColor?: unknown;
|
||||
};
|
||||
const rawHoverTokenBackgroundColor =
|
||||
subtitleStyleSource.hoverTokenBackgroundColor !== undefined
|
||||
? subtitleStyleSource.hoverTokenBackgroundColor
|
||||
: subtitleStyleSource.hoverBackground;
|
||||
const hoverTokenBackgroundColor = asString(rawHoverTokenBackgroundColor);
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
} else if (
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
|
||||
undefined
|
||||
) {
|
||||
} else if (rawHoverTokenBackgroundColor !== undefined) {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor =
|
||||
fallbackSubtitleStyleHoverTokenBackgroundColor;
|
||||
warn(
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
||||
rawHoverTokenBackgroundColor,
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor,
|
||||
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
|
||||
);
|
||||
|
||||
@@ -6,12 +6,14 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
@@ -40,6 +41,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -119,6 +121,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
toggleVisibleOverlay: () => {
|
||||
calls.push('toggleVisibleOverlay');
|
||||
},
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -533,6 +538,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
expected: 'startPendingMineSentenceMultiple:2500',
|
||||
},
|
||||
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
|
||||
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
|
||||
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
|
||||
{
|
||||
args: { openRuntimeOptions: true },
|
||||
@@ -712,6 +718,34 @@ test('handleCliCommand sets character dictionary manual AniList selection', asyn
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not log character dictionary selection success when result is not ok', async () => {
|
||||
const { calls, deps } = createDeps({
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: false,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({
|
||||
dictionarySelect: true,
|
||||
dictionaryAnilistId: 21355,
|
||||
dictionaryTarget: '/tmp/re-zero.mkv',
|
||||
}),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
|
||||
assert.equal(
|
||||
calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
|
||||
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
|
||||
{ start: true },
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface CliCommandServiceDeps {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
@@ -138,6 +139,7 @@ interface OverlayCliRuntime {
|
||||
isInitialized: () => boolean;
|
||||
initialize: () => void;
|
||||
toggleVisible: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
setVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -244,6 +246,7 @@ export function createCliCommandDepsRuntime(
|
||||
isOverlayRuntimeInitialized: options.overlay.isInitialized,
|
||||
initializeOverlayRuntime: options.overlay.initialize,
|
||||
toggleVisibleOverlay: options.overlay.toggleVisible,
|
||||
togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: options.ui.openFirstRunSetup,
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
@@ -369,6 +372,8 @@ export function handleCliCommand(
|
||||
|
||||
if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
deps.togglePrimarySubtitleBar();
|
||||
} else if (args.setup) {
|
||||
deps.openFirstRunSetup();
|
||||
deps.log('Opened first-run setup flow.');
|
||||
@@ -640,6 +645,10 @@ export function handleCliCommand(
|
||||
mediaId: args.dictionaryAnilistId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
deps.warn('Character dictionary override was not saved.');
|
||||
return;
|
||||
}
|
||||
deps.log(
|
||||
`Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
commands: unknown[];
|
||||
mediaPath: string;
|
||||
restored: number;
|
||||
quitRequested: number;
|
||||
};
|
||||
} {
|
||||
const state = {
|
||||
@@ -28,6 +29,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
commands: [] as unknown[],
|
||||
mediaPath: '',
|
||||
restored: 0,
|
||||
quitRequested: 0,
|
||||
};
|
||||
const metrics: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
@@ -102,6 +104,10 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
restorePreviousSecondarySubVisibility: () => {
|
||||
state.restored += 1;
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => false,
|
||||
requestAppQuit: () => {
|
||||
state.quitRequested += 1;
|
||||
},
|
||||
setPreviousSecondarySubVisibility: () => {
|
||||
// intentionally not tracked in this unit test
|
||||
},
|
||||
@@ -223,6 +229,18 @@ test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', asy
|
||||
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
|
||||
|
||||
assert.equal(state.restored, 1);
|
||||
assert.equal(state.quitRequested, 0);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage quits app on managed playback shutdown', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
shouldQuitOnMpvShutdown: () => true,
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
|
||||
|
||||
assert.equal(state.restored, 1);
|
||||
assert.equal(state.quitRequested, 1);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
) => void;
|
||||
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -360,6 +362,9 @@ export async function dispatchMpvProtocolMessage(
|
||||
}
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
deps.requestAppQuit();
|
||||
}
|
||||
} else if (msg.request_id) {
|
||||
if (deps.resolvePendingRequest(msg.request_id, msg)) {
|
||||
return;
|
||||
|
||||
@@ -285,6 +285,25 @@ test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect
|
||||
assert.equal(timers.length, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient onClose requests app quit for managed playback', () => {
|
||||
let quitRequests = 0;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
shouldQuitOnMpvShutdown: () => true,
|
||||
requestAppQuit: () => {
|
||||
quitRequests += 1;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
(client as any).scheduleReconnect = () => {};
|
||||
|
||||
(client as any).transport.callbacks.onClose();
|
||||
|
||||
assert.equal(quitRequests, 1);
|
||||
});
|
||||
|
||||
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
}
|
||||
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
@@ -217,6 +219,10 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.playbackPaused = null;
|
||||
this.emit('connection-change', { connected: false });
|
||||
this.failPendingRequests();
|
||||
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
|
||||
this.deps.requestAppQuit?.();
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
});
|
||||
@@ -399,6 +405,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
restorePreviousSecondarySubVisibility: () => {
|
||||
this.restorePreviousSecondarySubVisibility();
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import { CliArgs } from '../../cli/args';
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
|
||||
@@ -4069,6 +4069,225 @@ test('tokenizeSubtitle clears all annotations for explanatory contrast endings',
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle clears annotations for ことに while preserving lexical N+1 target', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'さっきの俺と違うことに気付かないのかい?',
|
||||
makeDepsFromYomitanTokens(
|
||||
[
|
||||
{ surface: 'さっき', reading: 'さっき', headword: 'さっき' },
|
||||
{ surface: 'の', reading: 'の', headword: 'の' },
|
||||
{ surface: '俺', reading: 'おれ', headword: '俺' },
|
||||
{ surface: 'と', reading: 'と', headword: 'と' },
|
||||
{ surface: '違う', reading: 'ちがう', headword: '違う' },
|
||||
{ surface: 'ことに', reading: 'ことに', headword: '事' },
|
||||
{ surface: '気付かない', reading: 'きづかない', headword: '気付く' },
|
||||
{ surface: 'の', reading: 'の', headword: 'の' },
|
||||
{ surface: 'かい', reading: 'かい', headword: 'かい' },
|
||||
{ surface: '?', reading: '', headword: '?' },
|
||||
],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) =>
|
||||
text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null,
|
||||
getJlptLevel: (text) =>
|
||||
text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null,
|
||||
isKnownWord: (text) => ['さっき', 'の', '俺', 'と', '気付く', 'かい', '?'].includes(text),
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: 'さっき',
|
||||
surface: 'さっき',
|
||||
reading: 'サッキ',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '副詞可能',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'の',
|
||||
surface: 'の',
|
||||
reading: 'ノ',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '連体化',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '俺',
|
||||
surface: '俺',
|
||||
reading: 'オレ',
|
||||
startPos: 4,
|
||||
endPos: 5,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '代名詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'と',
|
||||
surface: 'と',
|
||||
reading: 'ト',
|
||||
startPos: 5,
|
||||
endPos: 6,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '格助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '違う',
|
||||
surface: '違う',
|
||||
reading: 'チガウ',
|
||||
startPos: 6,
|
||||
endPos: 8,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '事',
|
||||
surface: 'こと',
|
||||
reading: 'コト',
|
||||
startPos: 8,
|
||||
endPos: 10,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'に',
|
||||
surface: 'に',
|
||||
reading: 'ニ',
|
||||
startPos: 10,
|
||||
endPos: 11,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '格助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '気付く',
|
||||
surface: '気付か',
|
||||
reading: 'キヅカ',
|
||||
startPos: 11,
|
||||
endPos: 14,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'ない',
|
||||
surface: 'ない',
|
||||
reading: 'ナイ',
|
||||
startPos: 14,
|
||||
endPos: 16,
|
||||
partOfSpeech: PartOfSpeech.bound_auxiliary,
|
||||
pos1: '助動詞',
|
||||
pos2: '*',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'の',
|
||||
surface: 'の',
|
||||
reading: 'ノ',
|
||||
startPos: 16,
|
||||
endPos: 17,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '終助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'かい',
|
||||
surface: 'かい',
|
||||
reading: 'カイ',
|
||||
startPos: 17,
|
||||
endPos: 19,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '終助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '?',
|
||||
surface: '?',
|
||||
reading: '',
|
||||
startPos: 19,
|
||||
endPos: 20,
|
||||
partOfSpeech: PartOfSpeech.symbol,
|
||||
pos1: '記号',
|
||||
pos2: '一般',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const tokenSummary = result.tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
headword: token.headword,
|
||||
isKnown: token.isKnown,
|
||||
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||
frequencyRank: token.frequencyRank,
|
||||
jlptLevel: token.jlptLevel,
|
||||
}));
|
||||
|
||||
assert.deepEqual(
|
||||
tokenSummary?.find((token) => token.surface === 'ことに'),
|
||||
{
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
tokenSummary?.find((token) => token.surface === '違う'),
|
||||
{
|
||||
surface: '違う',
|
||||
headword: '違う',
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: true,
|
||||
frequencyRank: 900,
|
||||
jlptLevel: 'N4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => {
|
||||
let mecabCalls = 0;
|
||||
const result = await tokenizeSubtitle(
|
||||
|
||||
@@ -353,6 +353,19 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only demonstrative
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only non-independent noun helper merges', () => {
|
||||
const token = makeToken({
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
reading: 'コトニ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞|助詞',
|
||||
pos2: '非自立|格助詞',
|
||||
});
|
||||
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
});
|
||||
|
||||
test('stripSubtitleAnnotationMetadata keeps token hover data while clearing annotation fields', () => {
|
||||
const token = makeToken({
|
||||
surface: 'は',
|
||||
@@ -813,6 +826,36 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations for kana-only non-independent noun helper merges', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ことに',
|
||||
headword: '事',
|
||||
reading: 'コトニ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞|助詞',
|
||||
pos2: '非自立|格助詞',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
frequencyRank: 81,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '事',
|
||||
getJlptLevel: (text) => (text === '事' ? 'N4' : null),
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations from standalone あ interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -71,6 +71,7 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
|
||||
'ってば',
|
||||
]);
|
||||
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
|
||||
const NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1 = new Set(['助詞', '助動詞']);
|
||||
|
||||
export interface SubtitleAnnotationFilterOptions {
|
||||
pos1Exclusions?: ReadonlySet<string>;
|
||||
@@ -252,6 +253,31 @@ function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
|
||||
return pos3Parts.includes('助動詞語幹');
|
||||
}
|
||||
|
||||
function isKanaOnlyNonIndependentNounHelperMerge(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
if (
|
||||
!normalizedSurface ||
|
||||
!normalizedHeadword ||
|
||||
normalizedSurface === normalizedHeadword ||
|
||||
![...normalizedSurface].every(isKanaChar)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
if (pos1Parts.length < 2 || pos1Parts[0] !== '名詞') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
|
||||
if (pos2Parts[0] !== '非自立') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pos1Parts.slice(1).every((part) => NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1.has(part));
|
||||
}
|
||||
|
||||
function isExcludedByTerm(token: MergedToken): boolean {
|
||||
const candidates = [token.surface, token.reading, token.headword].filter(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
||||
@@ -335,6 +361,10 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isKanaOnlyNonIndependentNounHelperMerge(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExcludedTrailingParticleMergedToken(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+27
-13
@@ -534,6 +534,7 @@ import {
|
||||
resolveSubtitleSourcePath,
|
||||
} from './main/runtime/subtitle-prefetch-source';
|
||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
|
||||
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
@@ -3822,6 +3823,8 @@ const {
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
appState.reconnectTimer = timer;
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
|
||||
requestAppQuit: () => requestAppQuit(),
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
|
||||
@@ -4345,6 +4348,10 @@ function toggleSubtitleSidebar(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
|
||||
}
|
||||
|
||||
function togglePrimarySubtitleBar(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle);
|
||||
}
|
||||
|
||||
async function triggerSubsyncFromConfig(): Promise<void> {
|
||||
await subsyncRuntime.triggerFromConfig();
|
||||
}
|
||||
@@ -4857,12 +4864,16 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
getCharacterDictionarySelection: () =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(),
|
||||
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||
const result = await characterDictionaryRuntime.setManualSelection({ mediaId });
|
||||
resetAnilistMediaGuessState();
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
return result;
|
||||
},
|
||||
setCharacterDictionarySelection: async (mediaId: number) =>
|
||||
applyCharacterDictionarySelection(
|
||||
{ mediaId },
|
||||
{
|
||||
setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request),
|
||||
resetAnilistMediaGuessState,
|
||||
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
|
||||
warn: (message, error) => logger.warn(message, error),
|
||||
},
|
||||
),
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
||||
...playlistBrowserMainDeps,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
@@ -4919,6 +4930,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => copyCurrentSubtitle(),
|
||||
@@ -4946,12 +4958,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
getCharacterDictionarySelection: async (targetPath?: string) =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(targetPath),
|
||||
setCharacterDictionarySelection: async (request) => {
|
||||
const result = await characterDictionaryRuntime.setManualSelection(request);
|
||||
resetAnilistMediaGuessState();
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
return result;
|
||||
},
|
||||
setCharacterDictionarySelection: async (request) =>
|
||||
applyCharacterDictionarySelection(request, {
|
||||
setManualSelection: (selectionRequest) =>
|
||||
characterDictionaryRuntime.setManualSelection(selectionRequest),
|
||||
resetAnilistMediaGuessState,
|
||||
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
|
||||
warn: (message, error) => logger.warn(message, error),
|
||||
}),
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
runStatsCliCommand(argsFromCommand, source),
|
||||
@@ -5125,7 +5139,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
|
||||
@@ -175,7 +175,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGuessInput = (targetPath?: string): { mediaPath: string | null; mediaTitle: string | null } => {
|
||||
const resolveGuessInput = (
|
||||
targetPath?: string,
|
||||
): { mediaPath: string | null; mediaTitle: string | null } => {
|
||||
const dictionaryTarget = targetPath?.trim() || '';
|
||||
return dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
@@ -324,13 +326,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
`[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`,
|
||||
);
|
||||
|
||||
return {
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
fromCache: false,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
};
|
||||
return {
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
fromCache: false,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { searchAniListMediaCandidates } from './fetch';
|
||||
|
||||
test('searchAniListMediaCandidates trims fallback candidate titles', async () => {
|
||||
const previousFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 21355, episodes: 25, title: {} }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const candidates = await searchAniListMediaCandidates(' Re:ZERO ');
|
||||
|
||||
assert.equal(candidates[0]?.title, 'Re:ZERO');
|
||||
} finally {
|
||||
if (previousFetchDescriptor) {
|
||||
Object.defineProperty(globalThis, 'fetch', previousFetchDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'fetch');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -136,13 +136,14 @@ function toAniListMediaCandidate(
|
||||
},
|
||||
fallbackTitle: string,
|
||||
): AniListMediaCandidate {
|
||||
const normalizedFallback = fallbackTitle.trim() || `AniList ${entry.id}`;
|
||||
return {
|
||||
id: entry.id,
|
||||
title:
|
||||
entry.title?.english?.trim() ||
|
||||
entry.title?.romaji?.trim() ||
|
||||
entry.title?.native?.trim() ||
|
||||
fallbackTitle,
|
||||
normalizedFallback,
|
||||
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,11 +98,7 @@ export function buildCharacterDictionarySeriesKey(input: {
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
const filePath = path.join(
|
||||
deps.userDataPath,
|
||||
'character-dictionaries',
|
||||
'anilist-overrides.json',
|
||||
);
|
||||
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyCharacterDictionarySelection } from './character-dictionary-selection';
|
||||
|
||||
test('applyCharacterDictionarySelection returns saved override when post-save sync fails', async () => {
|
||||
const warnings: unknown[] = [];
|
||||
const result = await applyCharacterDictionarySelection(
|
||||
{ mediaId: 21355 },
|
||||
{
|
||||
setManualSelection: async (request) => ({
|
||||
ok: true,
|
||||
seriesKey: `series-${request.mediaId}`,
|
||||
selected: { id: request.mediaId, title: 'Re:ZERO', episodes: 25 },
|
||||
staleMediaIds: [10607],
|
||||
}),
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
runSyncNow: async () => {
|
||||
throw new Error('sync failed');
|
||||
},
|
||||
warn: (...args) => warnings.push(args),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.selected.id, 21355);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CharacterDictionaryManualSelectionResult } from './character-dictionary-runtime/types';
|
||||
|
||||
export type CharacterDictionarySelectionRequest = {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionDeps = {
|
||||
setManualSelection: (
|
||||
request: CharacterDictionarySelectionRequest,
|
||||
) => Promise<CharacterDictionaryManualSelectionResult>;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
runSyncNow: () => Promise<void>;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
};
|
||||
|
||||
export async function applyCharacterDictionarySelection(
|
||||
request: CharacterDictionarySelectionRequest,
|
||||
deps: CharacterDictionarySelectionDeps,
|
||||
): Promise<CharacterDictionaryManualSelectionResult> {
|
||||
const result = await deps.setManualSelection(request);
|
||||
deps.resetAnilistMediaGuessState();
|
||||
try {
|
||||
await deps.runSyncNow();
|
||||
} catch (error) {
|
||||
deps.warn('Character dictionary auto-sync failed after manual selection', error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -83,6 +84,7 @@ function createCliCommandDepsFromContext(
|
||||
isInitialized: context.isOverlayInitialized,
|
||||
initialize: context.initializeOverlay,
|
||||
toggleVisible: context.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: context.togglePrimarySubtitleBar,
|
||||
setVisible: context.setVisibleOverlay,
|
||||
},
|
||||
mining: {
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
|
||||
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
|
||||
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
|
||||
togglePrimarySubtitleBar: CliCommandDepsRuntimeOptions['overlay']['togglePrimarySubtitleBar'];
|
||||
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
|
||||
};
|
||||
mining: {
|
||||
@@ -325,6 +326,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
isInitialized: params.overlay.isInitialized,
|
||||
initialize: params.overlay.initialize,
|
||||
toggleVisible: params.overlay.toggleVisible,
|
||||
togglePrimarySubtitleBar: params.overlay.togglePrimarySubtitleBar,
|
||||
setVisible: params.overlay.setVisible,
|
||||
},
|
||||
mining: {
|
||||
|
||||
@@ -467,10 +467,7 @@ test('auto sync removes stale manual-selection media ids when applying corrected
|
||||
path.join(dictionariesDir, 'auto-sync-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
activeMediaIds: [
|
||||
'10607 - Rerere no Tensai Bakabon',
|
||||
'130298 - The Eminence in Shadow',
|
||||
],
|
||||
activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'],
|
||||
mergedRevision: 'old',
|
||||
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => calls.push('init'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -69,6 +70,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
|
||||
@@ -26,6 +26,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
|
||||
@@ -29,6 +29,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('open-setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
@@ -94,6 +95,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
openFirstRunSetup: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
|
||||
@@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -81,6 +82,7 @@ export function createCliCommandContext(
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
|
||||
@@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
showMpvOsd: () => {},
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
|
||||
@@ -20,12 +20,14 @@ function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> |
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
|
||||
@@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
return Boolean(
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
|
||||
@@ -11,6 +11,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -24,6 +26,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
getReconnectTimer: () => deps.getReconnectTimer(),
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
|
||||
deps.setReconnectTimer(timer),
|
||||
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => deps.requestAppQuit?.(),
|
||||
},
|
||||
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ export type MpvClientRuntimeServiceOptions = {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
|
||||
@@ -41,7 +41,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
let initialized = false;
|
||||
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openOverlay();
|
||||
handlers.openSessionHelp();
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
@@ -56,7 +56,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
calls.push('init');
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -71,7 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
assert.deepEqual(template, [{ label: 'ok' }]);
|
||||
assert.deepEqual(calls, [
|
||||
'init',
|
||||
'visible:true',
|
||||
'help',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createResolveTrayIconPathHandler(deps: {
|
||||
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -41,7 +41,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
openSessionHelpModal: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -53,11 +53,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}) {
|
||||
return (): TMenuItem[] => {
|
||||
return deps.buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => {
|
||||
openSessionHelp: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.openSessionHelpModal();
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
|
||||
@@ -24,7 +24,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never,
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -36,7 +36,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
})();
|
||||
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('open-overlay'),
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -40,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
openSessionHelpModal: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -54,7 +54,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
|
||||
@@ -19,14 +19,12 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
fileExists: () => true,
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Overlay' }],
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Help' }],
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayInitialized = true;
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlay = visible;
|
||||
},
|
||||
openSessionHelpModal: () => {},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -88,7 +86,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
});
|
||||
|
||||
assert.equal(runtime.resolveTrayIconPath(), '/tmp/SubMiner.png');
|
||||
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Overlay' }] });
|
||||
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Help' }] });
|
||||
runtime.ensureTray();
|
||||
assert.equal(overlayInitialized, true);
|
||||
assert.equal(visibleOverlay, true);
|
||||
|
||||
@@ -29,7 +29,7 @@ test('resolve tray icon returns null when no asset exists', () => {
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
@@ -42,15 +42,17 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
});
|
||||
|
||||
assert.equal(template.length, 9);
|
||||
assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false);
|
||||
assert.equal(template[0]!.label, 'Open Help');
|
||||
template[0]!.click?.();
|
||||
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[8]!.click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
assert.deepEqual(calls, ['help', 'separator', 'quit']);
|
||||
});
|
||||
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => undefined,
|
||||
openSessionHelp: () => undefined,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
}
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -49,8 +49,8 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
label: 'Open Overlay',
|
||||
click: handlers.openOverlay,
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
...(handlers.showFirstRunSetup
|
||||
? [
|
||||
|
||||
@@ -153,6 +153,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
|
||||
const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.subtitleSidebarToggle,
|
||||
);
|
||||
const onPrimarySubtitleBarToggleEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.primarySubtitleBarToggle,
|
||||
);
|
||||
const onKikuFieldGroupingRequestEvent =
|
||||
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
|
||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||
@@ -345,6 +348,7 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
|
||||
@@ -330,6 +330,7 @@ function installKeyboardTestGlobals() {
|
||||
function createKeyboardHandlerHarness() {
|
||||
const testGlobals = installKeyboardTestGlobals();
|
||||
const subtitleRootClassList = createClassList();
|
||||
const subtitleContainerClassList = createClassList();
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
@@ -349,6 +350,7 @@ function createKeyboardHandlerHarness() {
|
||||
querySelectorAll: () => wordNodes,
|
||||
},
|
||||
subtitleContainer: {
|
||||
classList: subtitleContainerClassList,
|
||||
contains: () => false,
|
||||
},
|
||||
overlay: testGlobals.overlay,
|
||||
@@ -405,6 +407,26 @@ function createKeyboardHandlerHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('primary subtitle visibility key hides and restores the subtitle bar without mpv sub-visibility', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
|
||||
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
|
||||
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
|
||||
assert.equal(
|
||||
testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session help chord resolver follows remapped session bindings', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -361,6 +361,20 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean {
|
||||
return e.code === 'KeyV' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat;
|
||||
}
|
||||
|
||||
function togglePrimarySubtitleBarVisibility(): void {
|
||||
const visible = !ctx.state.primarySubtitleBarVisible;
|
||||
ctx.state.primarySubtitleBarVisible = visible;
|
||||
if (visible) {
|
||||
ctx.dom.subtitleContainer.classList.remove('primary-sub-hidden');
|
||||
} else {
|
||||
ctx.dom.subtitleContainer.classList.add('primary-sub-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkWatched(): Promise<void> {
|
||||
const marked = await window.electronAPI.markActiveVideoWatched();
|
||||
if (marked) {
|
||||
@@ -1065,6 +1079,12 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPrimarySubtitleVisibilityToggle(e)) {
|
||||
e.preventDefault();
|
||||
togglePrimarySubtitleBarVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||
if (handleYomitanPopupKeybind(e)) {
|
||||
e.preventDefault();
|
||||
@@ -1152,6 +1172,7 @@ export function createKeyboardHandlers(
|
||||
updateSessionBindings,
|
||||
syncKeyboardTokenSelection,
|
||||
handleSubtitleContentUpdated,
|
||||
togglePrimarySubtitleBarVisibility,
|
||||
handleKeyboardModeToggleRequested,
|
||||
handleLookupWindowToggleRequested,
|
||||
closeLookupWindow,
|
||||
|
||||
@@ -38,12 +38,18 @@ function createElementStub() {
|
||||
}
|
||||
|
||||
function createNodeStub(hidden = false) {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
textContent: '',
|
||||
children: [] as unknown[],
|
||||
classList: createClassList(hidden ? ['hidden'] : []),
|
||||
setAttribute: () => {},
|
||||
addEventListener: () => {},
|
||||
addEventListener: (event: string, listener: () => void) => {
|
||||
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||
},
|
||||
dispatchEvent: (event: string) => {
|
||||
for (const listener of listeners.get(event) ?? []) listener();
|
||||
},
|
||||
replaceChildren(...children: unknown[]) {
|
||||
this.children = [...children];
|
||||
},
|
||||
@@ -69,6 +75,7 @@ test('character dictionary modal loads candidates and applies selected override'
|
||||
const calls: number[] = [];
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const closeButton = createNodeStub();
|
||||
const candidates = createNodeStub();
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
@@ -110,7 +117,7 @@ test('character dictionary modal loads candidates and applies selected override'
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: createNodeStub(),
|
||||
characterDictionaryClose: closeButton,
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: candidates,
|
||||
@@ -122,6 +129,7 @@ test('character dictionary modal loads candidates and applies selected override'
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
modal.wireDomEvents();
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
@@ -137,8 +145,71 @@ test('character dictionary modal loads candidates and applies selected override'
|
||||
|
||||
assert.deepEqual(calls, [21355]);
|
||||
assert.match(status.textContent, /Override saved/);
|
||||
|
||||
closeButton.dispatchEvent('click');
|
||||
assert.equal(state.characterDictionaryModalOpen, false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('character dictionary modal shows refresh errors without rejecting open', async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const overlay = createNodeStub();
|
||||
const modalNode = createNodeStub(true);
|
||||
const status = createNodeStub();
|
||||
const state = createRendererState();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
getCharacterDictionarySelection: async () => {
|
||||
throw new Error('candidate lookup failed');
|
||||
},
|
||||
setCharacterDictionarySelection: async () => ({
|
||||
ok: false,
|
||||
seriesKey: 'test',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
}),
|
||||
notifyOverlayModalClosed: () => {},
|
||||
} satisfies Pick<
|
||||
ElectronAPI,
|
||||
| 'getCharacterDictionarySelection'
|
||||
| 'setCharacterDictionarySelection'
|
||||
| 'notifyOverlayModalClosed'
|
||||
>,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const modal = createCharacterDictionaryModal(
|
||||
{
|
||||
state,
|
||||
dom: {
|
||||
overlay,
|
||||
characterDictionaryModal: modalNode,
|
||||
characterDictionaryClose: createNodeStub(),
|
||||
characterDictionarySummary: createNodeStub(),
|
||||
characterDictionaryCurrent: createNodeStub(),
|
||||
characterDictionaryCandidates: createNodeStub(),
|
||||
characterDictionaryStatus: status,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
await modal.openCharacterDictionaryModal();
|
||||
|
||||
assert.equal(state.characterDictionaryModalOpen, true);
|
||||
assert.equal(status.textContent, 'candidate lookup failed');
|
||||
assert.equal(status.classList.contains('error'), true);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,7 +162,11 @@ export function createCharacterDictionaryModal(
|
||||
} else {
|
||||
setStatus('Refreshing AniList candidates...');
|
||||
}
|
||||
await refreshSelection();
|
||||
try {
|
||||
await refreshSelection();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCharacterDictionaryModal(): void {
|
||||
@@ -214,11 +218,14 @@ export function createCharacterDictionaryModal(
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||
}
|
||||
|
||||
return {
|
||||
openCharacterDictionaryModal,
|
||||
closeCharacterDictionaryModal,
|
||||
handleCharacterDictionaryKeydown,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import {
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
} from './session-help.js';
|
||||
|
||||
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
|
||||
});
|
||||
|
||||
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
|
||||
'Shift subtitle delay to next cue',
|
||||
);
|
||||
assert.equal(
|
||||
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
|
||||
'Shift subtitle delay to previous cue',
|
||||
);
|
||||
});
|
||||
|
||||
test('session help formats bracket keybindings as physical keys', () => {
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketRight'), 'Shift + ]');
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
|
||||
});
|
||||
@@ -44,6 +44,8 @@ const KEY_NAME_MAP: Record<string, string> = {
|
||||
Escape: 'Esc',
|
||||
Tab: 'Tab',
|
||||
Enter: 'Enter',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
CommandOrControl: 'Cmd/Ctrl',
|
||||
Ctrl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
@@ -132,7 +134,9 @@ function describeCommand(command: (string | number)[]): string {
|
||||
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
|
||||
}
|
||||
if (first === 'sub-seek' && typeof command[1] === 'number') {
|
||||
return `Shift subtitle by ${command[1]} ms`;
|
||||
if (command[1] > 0) return 'Jump to next subtitle';
|
||||
if (command[1] < 0) return 'Jump to previous subtitle';
|
||||
return 'Reload current subtitle timing';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
|
||||
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
|
||||
@@ -140,6 +144,12 @@ function describeCommand(command: (string | number)[]): string {
|
||||
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
|
||||
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
|
||||
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to next cue';
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
|
||||
return 'Shift subtitle delay to previous cue';
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
const [, rawId, rawDirection] = first.split(':');
|
||||
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
|
||||
@@ -148,6 +158,11 @@ function describeCommand(command: (string | number)[]): string {
|
||||
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
|
||||
}
|
||||
|
||||
export {
|
||||
describeCommand as describeSessionHelpCommand,
|
||||
formatKeybinding as formatSessionHelpKeybinding,
|
||||
};
|
||||
|
||||
function sectionForCommand(command: (string | number)[]): string {
|
||||
const first = command[0];
|
||||
if (typeof first !== 'string') return 'Other shortcuts';
|
||||
|
||||
@@ -532,6 +532,12 @@ function registerKeyboardCommandHandlers(): void {
|
||||
await subtitleSidebarModal.toggleSubtitleSidebarModal();
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onPrimarySubtitleBarToggle(() => {
|
||||
runGuarded('primary-subtitle-bar:toggle', () => {
|
||||
keyboardHandlers.togglePrimarySubtitleBarVisibility();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runGuarded(action: string, fn: () => void): void {
|
||||
@@ -651,6 +657,7 @@ async function init(): Promise<void> {
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
|
||||
@@ -134,6 +134,7 @@ export type RendererState = {
|
||||
keyboardSelectionVisible: boolean;
|
||||
keyboardSelectedWordIndex: number | null;
|
||||
yomitanPopupVisible: boolean;
|
||||
primarySubtitleBarVisible: boolean;
|
||||
};
|
||||
|
||||
export function createRendererState(): RendererState {
|
||||
@@ -244,5 +245,6 @@ export function createRendererState(): RendererState {
|
||||
keyboardSelectionVisible: false,
|
||||
keyboardSelectedWordIndex: null,
|
||||
yomitanPopupVisible: false,
|
||||
primarySubtitleBarVisible: true,
|
||||
};
|
||||
}
|
||||
|
||||
+81
-43
@@ -678,6 +678,11 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#subtitleContainer.primary-sub-hidden {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.settings-modal-open #subtitleContainer {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
@@ -778,88 +783,121 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
|
||||
#subtitleRoot .word.word-known {
|
||||
color: var(--subtitle-known-word-color, #a6da95);
|
||||
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-n-plus-one {
|
||||
color: var(--subtitle-n-plus-one-color, #c6a0f6);
|
||||
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-name-match {
|
||||
color: var(--subtitle-name-match-color, #f5bde6);
|
||||
text-shadow: 0 0 6px rgba(245, 189, 230, 0.35);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
|
||||
padding-bottom: 1px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
#subtitleRoot
|
||||
.word.word-jlpt-n1:not(
|
||||
:is(
|
||||
.word-known,
|
||||
.word-n-plus-one,
|
||||
.word-name-match,
|
||||
.word-frequency-single,
|
||||
.word-frequency-band-1,
|
||||
.word-frequency-band-2,
|
||||
.word-frequency-band-3,
|
||||
.word-frequency-band-4,
|
||||
.word-frequency-band-5
|
||||
)
|
||||
) {
|
||||
color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2 {
|
||||
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
|
||||
padding-bottom: 1px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
#subtitleRoot
|
||||
.word.word-jlpt-n2:not(
|
||||
:is(
|
||||
.word-known,
|
||||
.word-n-plus-one,
|
||||
.word-name-match,
|
||||
.word-frequency-single,
|
||||
.word-frequency-band-1,
|
||||
.word-frequency-band-2,
|
||||
.word-frequency-band-3,
|
||||
.word-frequency-band-4,
|
||||
.word-frequency-band-5
|
||||
)
|
||||
) {
|
||||
color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3 {
|
||||
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
|
||||
padding-bottom: 1px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
#subtitleRoot
|
||||
.word.word-jlpt-n3:not(
|
||||
:is(
|
||||
.word-known,
|
||||
.word-n-plus-one,
|
||||
.word-name-match,
|
||||
.word-frequency-single,
|
||||
.word-frequency-band-1,
|
||||
.word-frequency-band-2,
|
||||
.word-frequency-band-3,
|
||||
.word-frequency-band-4,
|
||||
.word-frequency-band-5
|
||||
)
|
||||
) {
|
||||
color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4 {
|
||||
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
|
||||
padding-bottom: 1px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
#subtitleRoot
|
||||
.word.word-jlpt-n4:not(
|
||||
:is(
|
||||
.word-known,
|
||||
.word-n-plus-one,
|
||||
.word-name-match,
|
||||
.word-frequency-single,
|
||||
.word-frequency-band-1,
|
||||
.word-frequency-band-2,
|
||||
.word-frequency-band-3,
|
||||
.word-frequency-band-4,
|
||||
.word-frequency-band-5
|
||||
)
|
||||
) {
|
||||
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5 {
|
||||
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
|
||||
padding-bottom: 1px;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
#subtitleRoot
|
||||
.word.word-jlpt-n5:not(
|
||||
:is(
|
||||
.word-known,
|
||||
.word-n-plus-one,
|
||||
.word-name-match,
|
||||
.word-frequency-single,
|
||||
.word-frequency-band-1,
|
||||
.word-frequency-band-2,
|
||||
.word-frequency-band-3,
|
||||
.word-frequency-band-4,
|
||||
.word-frequency-band-5
|
||||
)
|
||||
) {
|
||||
color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
|
||||
color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single,
|
||||
#subtitleRoot .word.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single {
|
||||
color: var(--subtitle-frequency-single-color, #f5a97f);
|
||||
}
|
||||
@@ -907,7 +945,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
#subtitleRoot .word.word-frequency-band-5:hover {
|
||||
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
|
||||
border-radius: 3px;
|
||||
font-weight: 800;
|
||||
filter: brightness(1.18) saturate(1.08);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-known .c:hover,
|
||||
|
||||
@@ -220,6 +220,22 @@ function normalizeCssSelector(selector: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildJlptColorSelector(level: number): string {
|
||||
const higherPriorityClasses = [
|
||||
'.word-known',
|
||||
'.word-n-plus-one',
|
||||
'.word-name-match',
|
||||
'.word-frequency-single',
|
||||
'.word-frequency-band-1',
|
||||
'.word-frequency-band-2',
|
||||
'.word-frequency-band-3',
|
||||
'.word-frequency-band-4',
|
||||
'.word-frequency-band-5',
|
||||
].join(', ');
|
||||
|
||||
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
|
||||
}
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
const knownJlpt = createToken({
|
||||
isKnown: true,
|
||||
@@ -410,6 +426,96 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
|
||||
}
|
||||
});
|
||||
|
||||
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const subtitleContainer = new FakeElement('div');
|
||||
const secondarySubRoot = new FakeElement('div');
|
||||
const secondarySubContainer = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer,
|
||||
secondarySubRoot,
|
||||
secondarySubContainer,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.applySubtitleStyle({
|
||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
fontColor: '#cad3f5',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.35,
|
||||
letterSpacing: '-0.01em',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '3px 0 0 #000, -3px 0 0 #000, 0 3px 0 #000, 0 -3px 0 #000, 2px 2px 0 #000',
|
||||
frequencyDictionary: {
|
||||
enabled: true,
|
||||
topX: 10000,
|
||||
mode: 'single',
|
||||
singleColor: '#f5a97f',
|
||||
},
|
||||
enableJlpt: true,
|
||||
jlptColors: {
|
||||
N1: '#ed8796',
|
||||
N2: '#f5a97f',
|
||||
N3: '#f9e2af',
|
||||
N4: '#a6e3a1',
|
||||
N5: '#8aadf4',
|
||||
},
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
knownWordColor: '#a6da95',
|
||||
} as never);
|
||||
|
||||
renderer.renderSubtitle({
|
||||
text: 'お礼をされるようなことしてない',
|
||||
tokens: [
|
||||
createToken({ surface: 'お礼', isKnown: true }),
|
||||
createToken({ surface: 'を' }),
|
||||
createToken({ surface: 'される', jlptLevel: 'N4' }),
|
||||
createToken({ surface: 'ような', frequencyRank: 15 }),
|
||||
],
|
||||
});
|
||||
|
||||
const rootStyle = subtitleRoot.style as unknown as Record<string, string>;
|
||||
assert.equal(rootStyle.fontFamily, 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP');
|
||||
assert.equal(rootStyle.fontSize, '35px');
|
||||
assert.equal(rootStyle.color, '#cad3f5');
|
||||
assert.equal(rootStyle.fontWeight, '700');
|
||||
assert.equal(rootStyle.lineHeight, '1.35');
|
||||
assert.equal(rootStyle.letterSpacing, '-0.01em');
|
||||
assert.equal(rootStyle.textRendering, 'geometricPrecision');
|
||||
assert.match(rootStyle.textShadow ?? '', /3px 0 0 #000/);
|
||||
|
||||
const wordNodes = collectWordNodes(subtitleRoot);
|
||||
assert.deepEqual(
|
||||
wordNodes.map((node) => [node.textContent, node.className]),
|
||||
[
|
||||
['お礼', 'word word-known'],
|
||||
['を', 'word'],
|
||||
['される', 'word word-jlpt-n4'],
|
||||
['ような', 'word word-frequency-single'],
|
||||
],
|
||||
);
|
||||
for (const wordNode of wordNodes) {
|
||||
const tokenStyle = wordNode.style as unknown as Record<string, string>;
|
||||
assert.equal(tokenStyle.fontFamily, undefined);
|
||||
assert.equal(tokenStyle.fontSize, undefined);
|
||||
assert.equal(tokenStyle.fontWeight, undefined);
|
||||
assert.equal(tokenStyle.lineHeight, undefined);
|
||||
assert.equal(tokenStyle.letterSpacing, undefined);
|
||||
assert.equal(tokenStyle.textRendering, undefined);
|
||||
assert.equal(tokenStyle.textShadow, undefined);
|
||||
}
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||||
const token = createToken({
|
||||
surface: '猫',
|
||||
@@ -552,6 +658,36 @@ test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
|
||||
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
|
||||
});
|
||||
|
||||
test('applySubtitleStyle keeps transparent hover token background', () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
try {
|
||||
const subtitleRoot = new FakeElement('div');
|
||||
const subtitleContainer = new FakeElement('div');
|
||||
const secondarySubRoot = new FakeElement('div');
|
||||
const secondarySubContainer = new FakeElement('div');
|
||||
const ctx = {
|
||||
state: createRendererState(),
|
||||
dom: {
|
||||
subtitleRoot,
|
||||
subtitleContainer,
|
||||
secondarySubRoot,
|
||||
secondarySubContainer,
|
||||
},
|
||||
} as never;
|
||||
|
||||
const renderer = createSubtitleRenderer(ctx);
|
||||
renderer.applySubtitleStyle({
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
} as never);
|
||||
|
||||
const rootStyleValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
|
||||
.values;
|
||||
assert.equal(rootStyleValues?.get('--subtitle-hover-token-background-color'), 'transparent');
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
|
||||
@@ -749,7 +885,7 @@ test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist',
|
||||
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
||||
});
|
||||
|
||||
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
test('subtitle annotation CSS changes token color without overriding typography', () => {
|
||||
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
|
||||
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
|
||||
|
||||
@@ -763,17 +899,27 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
const cssText = fs.readFileSync(cssPath, 'utf-8');
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
|
||||
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
|
||||
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
|
||||
|
||||
const block = extractClassBlock(cssText, buildJlptColorSelector(level));
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.match(
|
||||
block,
|
||||
new RegExp(`--subtitle-jlpt-underline-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
|
||||
);
|
||||
assert.match(block, /border-bottom:\s*2px solid var\(--subtitle-jlpt-underline-color\);/);
|
||||
assert.match(block, /padding-bottom:\s*1px;/);
|
||||
assert.match(block, /box-decoration-break:\s*clone;/);
|
||||
assert.match(block, /-webkit-box-decoration-break:\s*clone;/);
|
||||
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
|
||||
assert.match(block, new RegExp(`color:\\s*var\\(--subtitle-jlpt-n${level}-color,`));
|
||||
assert.doesNotMatch(block, /border-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /padding-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /text-shadow\s*:/);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
'#subtitleRoot .word.word-known',
|
||||
'#subtitleRoot .word.word-n-plus-one',
|
||||
'#subtitleRoot .word.word-name-match',
|
||||
]) {
|
||||
const block = extractClassBlock(cssText, selector);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
assert.doesNotMatch(block, /text-shadow\s*:/);
|
||||
}
|
||||
|
||||
for (let band = 1; band <= 5; band += 1) {
|
||||
@@ -873,7 +1019,8 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||||
);
|
||||
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
|
||||
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
|
||||
assert.match(coloredWordHoverBlock, /filter:\s*brightness\(1\.18\) saturate\(1\.08\);/);
|
||||
assert.doesNotMatch(coloredWordHoverBlock, /font-weight\s*:/);
|
||||
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
|
||||
assert.doesNotMatch(
|
||||
coloredWordHoverBlock,
|
||||
|
||||
@@ -120,6 +120,7 @@ export const IPC_CHANNELS = {
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||
configHotReload: 'config:hot-reload',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -433,6 +433,7 @@ export interface ElectronAPI {
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onOpenCharacterDictionary: (callback: () => void) => void;
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
|
||||
Reference in New Issue
Block a user