Compare commits

...

14 Commits

Author SHA1 Message Date
sudacode f28821a8cb fix: correct session help subtitle binding labels 2026-04-25 21:37:22 -07:00
sudacode a75c83e25e feat: open session help from tray 2026-04-25 21:32:10 -07:00
sudacode e51bb74e1b fix(config): validate null hover background 2026-04-25 21:30:31 -07:00
sudacode 5bccb55afc fix: exit managed playback on mpv socket close 2026-04-25 20:53:53 -07:00
sudacode ea8071f676 fix(overlay): show annotated subtitle hover affordance 2026-04-25 20:43:07 -07:00
sudacode ac93a5bd2e fix: address PR review follow-ups 2026-04-25 20:34:18 -07:00
sudacode ba48db6255 fix(character-dictionary): normalize fallback titles and wire close button
- Trim blank fallback titles in toAniListMediaCandidate; fall back to
  \"AniList <id>\" when all title fields and the fallback string are empty
- Add fetch unit test covering the trimmed-fallback path
- Extract wireDomEvents from createCharacterDictionaryModal so event
  listeners are bound explicitly after construction
- Call wireDomEvents in renderer init alongside other modal wiring
- Extend modal test to cover close-button click dismissing the modal
2026-04-25 20:03:51 -07:00
sudacode 992856ac5e fix(launcher): reject --candidates and --select when used together
- Validate mutually exclusive dictionary CLI flags; exit 1 with clear error
- Add parseArgs test covering the conflicting-flags rejection path
- Fix test helper to overwrite capture file (>) instead of appending (>>)
2026-04-25 20:03:44 -07:00
sudacode de19c40118 refactor(character-dictionary): extract applyCharacterDictionarySelection helper
- Add `applyCharacterDictionarySelection` in its own module with injected deps
- Catches sync errors and emits a warning instead of propagating
- Remove duplicated inline logic from IPC and CLI startup handlers in main.ts
- Add unit test covering sync-failure resilience
2026-04-25 20:03:38 -07:00
sudacode a05a698774 fix(mpv): avoid crash notification on video close 2026-04-25 19:45:02 -07:00
sudacode 7a08382c23 fix: overwrite manual subtitle audio fields 2026-04-25 19:42:33 -07:00
sudacode 2c01baafc9 fix: exclude kana grammar helper annotations 2026-04-25 19:41:36 -07:00
sudacode 76b546c8f6 fix: honor subtitle annotation style priority 2026-04-25 17:10:54 -07:00
sudacode c9df5b7624 feat: add primary subtitle bar toggle 2026-04-25 17:09:42 -07:00
94 changed files with 1956 additions and 197 deletions
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -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 -->
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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 |
+3
View File
@@ -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:
+4
View File
@@ -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
+3
View File
@@ -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.
+7 -2
View File
@@ -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
+1 -1
View File
@@ -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);
+8
View File
@@ -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
View File
@@ -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",
+3 -6
View File
@@ -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)
+19
View File
@@ -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,
+3
View File
@@ -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()
+57 -2
View File
@@ -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],
);
});
+19 -6
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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 ||
+1
View File
@@ -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
+40
View File
@@ -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(
+11 -8
View File
@@ -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()).',
);
+2
View File
@@ -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,
+34
View File
@@ -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 },
+9
View File
@@ -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}`,
);
+18
View File
@@ -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 () => {
+5
View File
@@ -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;
+19
View File
@@ -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());
+8
View File
@@ -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,
+219
View File
@@ -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
View File
@@ -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',
+10 -8
View File
@@ -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;
}
+2
View File
@@ -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: {
+2
View File
@@ -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: () => {},
+2
View File
@@ -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 = {
+3 -3
View File
@@ -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',
+4 -4
View File
@@ -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();
+2 -2
View File
@@ -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'),
+3 -3
View File
@@ -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);
+5 -3
View File
@@ -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,
+3 -3
View File
@@ -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
? [
+4
View File
@@ -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,
+22
View File
@@ -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();
+21
View File
@@ -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 });
}
});
+9 -2
View File
@@ -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,
};
}
+29
View File
@@ -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 + [');
});
+16 -1
View File
@@ -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';
+7
View File
@@ -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();
});
+2
View File
@@ -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
View File
@@ -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,
+159 -12
View File
@@ -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,
+1
View File
@@ -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;
+1
View File
@@ -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;