diff --git a/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md b/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md new file mode 100644 index 00000000..582bcf69 --- /dev/null +++ b/backlog/tasks/task-291 - Add-manual-AniList-selection-for-character-dictionary-resolution.md @@ -0,0 +1,43 @@ +--- +id: TASK-291 +title: Add manual AniList selection for character dictionary resolution +status: Done +assignee: + - '@codex' +created_date: '2026-04-25 21:29' +updated_date: '2026-04-25 22:51' +labels: + - dictionary + - anilist + - cli + - ui +dependencies: [] +priority: high +--- + +## Description + + +Add CLI and in-app UI support for correcting character dictionary anime resolution when guessit or AniList search picks the wrong series. Manual selections must apply to the whole detected series, persist across episodes, and replace stale incorrect entries in the auto-sync merged character dictionary state. Do not add tray UI. Known regression case: `Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv` previously resolved to `10607 - Rerere no Tensai Bakabon`; it should be correctable to the chosen Re:ZERO AniList media and then reused for later files in that series. + + +## Acceptance Criteria + +- [x] #1 CLI can show AniList candidate matches for the current/target media and set a manual character-dictionary AniList override. +- [x] #2 In-app UI can show the current character-dictionary match, candidate matches, and apply an override without adding tray controls. +- [x] #3 Persisted overrides are keyed at series scope so all later episodes in the same series reuse the selected AniList media. +- [x] #4 Applying an override clears stale guess state, replaces the old incorrect active media entry in auto-sync state, rebuilds/imports the merged character dictionary, and refreshes subtitle dictionary usage. +- [x] #5 Regression tests cover the Re:ZERO filename, override reuse, stale active-media replacement, CLI handling, and IPC/UI contract behavior. +- [x] #6 Docs are updated for manual character dictionary anime selection. + + +## Implementation Plan + + +1. Add a focused override/resolution layer for character dictionary media selection: derive a stable series key from filename/guessit data, persist manual AniList media overrides under user data, and expose AniList candidate search helpers. +2. Update character dictionary snapshot resolution to check manual overrides before guessit-derived AniList search, and update auto-sync so applying an override removes stale incorrect media IDs and rebuilds/imports the merged dictionary. +3. Extend CLI with commands to list candidates and set an override for current or target media. +4. Extend existing in-app settings UI via IPC/preload contracts: show current match/candidates and let user apply an override. No tray controls. +5. Use TDD: add failing regressions first for Re:ZERO parsing/override behavior, auto-sync replacement, CLI handling, IPC contract, and UI state; then implement. +6. Update docs-site/manual docs for manual character dictionary anime selection, launcher usage, and the default `Ctrl+Alt+A` modal shortcut, then run focused tests and broader gates as time permits. + diff --git a/backlog/tasks/task-292 - Restore-Linux-multi-subtitle-copy-digit-capture.md b/backlog/tasks/task-292 - Restore-Linux-multi-subtitle-copy-digit-capture.md new file mode 100644 index 00000000..5ef5b4cc --- /dev/null +++ b/backlog/tasks/task-292 - Restore-Linux-multi-subtitle-copy-digit-capture.md @@ -0,0 +1,52 @@ +--- +id: TASK-292 +title: Restore Linux multi-subtitle copy digit capture +status: Done +assignee: + - '@codex' +created_date: '2026-04-25 21:31' +updated_date: '2026-04-25 21:36' +labels: + - bug + - linux + - shortcuts + - clipboard +dependencies: [] +priority: high +--- + +## Description + + +On Linux, the copy-subtitle-multiple shortcut opens the numeric prompt but the follow-up digit is not captured, so the flow times out. User confirmed `wl-copy` itself is installed and working, so investigate the shortcut/digit capture path and restore multi-line subtitle copy without regressing existing session action behavior. + + +## Acceptance Criteria + +- [x] #1 Linux copy-subtitle-multiple shortcut accepts a follow-up digit and copies that number of recent subtitle lines instead of timing out. +- [x] #2 The fix avoids depending on Linux Electron global shortcut digit registration for the follow-up numeric selection when a renderer-visible session can handle it. +- [x] #3 Regression tests cover the Linux multi-copy shortcut/digit flow and existing non-Linux/global shortcut behavior remains intact. + + +## Implementation Plan + + +1. Add failing regression coverage for Linux copy-subtitle-multiple local shortcut fallback starting renderer/session numeric selection instead of main-process digit globalShortcut capture. +2. Patch the overlay shortcut fallback/runtime path so Linux visible-overlay multi-copy and mine-sentence-multiple can dispatch session-action numeric selection when renderer handling is available, while preserving main-process numeric sessions for CLI/non-renderer paths. +3. Run targeted tests for shortcut fallback, overlay runtime, and renderer keyboard numeric selection; then run typecheck or a wider focused gate if needed. +4. Update task acceptance criteria/final notes after verification. + + +## Implementation Notes + + +Implemented the approved path by keeping multi-step numeric overlay shortcuts out of the main-process local fallback. The visible overlay now receives the original keydown and uses the existing renderer/session-action numeric selection flow for follow-up digits, avoiding Linux Electron globalShortcut digit capture for multi-copy and mine-sentence-multiple. Verification: targeted shortcut/renderer tests and changelog lint pass. `bun run typecheck` is currently blocked by unrelated existing errors in CLI/AniList dictionary-candidate work and `src/main/dependencies.ts` manual-selection API shape. + + +## Final Summary + + +Restored Linux multi-line subtitle copy by preventing main-process overlay shortcut fallback from consuming multi-step numeric shortcuts (`copySubtitleMultiple` and `mineSentenceMultiple`). Those shortcuts now fall through to the visible overlay renderer, where the existing session binding flow prompts for a digit and dispatches the counted session action locally instead of relying on Electron globalShortcut digit registration. Added regression coverage for the fallback behavior and renderer follow-up digit dispatch, plus a changelog fragment. + +Verification: `bun test src/core/services/overlay-shortcut-handler.test.ts src/renderer/handlers/keyboard.test.ts`; `bun run changelog:lint`. Full `bun run typecheck` was attempted but is blocked by unrelated current worktree errors in CLI/AniList dictionary-candidate tests/types and `src/main/dependencies.ts`. + diff --git a/backlog/tasks/task-293 - Fix-interjection-tokens-receiving-subtitle-annotations.md b/backlog/tasks/task-293 - Fix-interjection-tokens-receiving-subtitle-annotations.md new file mode 100644 index 00000000..874b37df --- /dev/null +++ b/backlog/tasks/task-293 - Fix-interjection-tokens-receiving-subtitle-annotations.md @@ -0,0 +1,25 @@ +--- +id: TASK-293 +title: Fix interjection tokens receiving subtitle annotations +status: In Progress +assignee: [] +created_date: '2026-04-25 22:50' +labels: + - tokenizer + - bug +dependencies: [] +priority: medium +--- + +## Description + + +Standalone interjections such as あ should remain hoverable dictionary tokens but must not receive N+1, frequency, JLPT, or known-word subtitle annotation metadata. + + +## Acceptance Criteria + +- [ ] #1 A MeCab 感動詞 token like あ is excluded by the shared subtitle annotation gate. +- [ ] #2 annotateTokens strips N+1/frequency/JLPT/known metadata from the interjection while preserving token lookup fields. +- [ ] #3 Focused tokenizer regression passes. + diff --git a/backlog/tasks/task-294 - Fix-annotated-subtitle-tokens-to-honor-subtitleStyle.md b/backlog/tasks/task-294 - Fix-annotated-subtitle-tokens-to-honor-subtitleStyle.md new file mode 100644 index 00000000..c07f6a85 --- /dev/null +++ b/backlog/tasks/task-294 - Fix-annotated-subtitle-tokens-to-honor-subtitleStyle.md @@ -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 + + +Annotated token spans should inherit the configured subtitleStyle typography and only use annotation metadata to change token color. + + +## Acceptance Criteria + +- [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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md b/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md new file mode 100644 index 00000000..844b22d2 --- /dev/null +++ b/backlog/tasks/task-295 - Add-primary-subtitle-visibility-keybinding.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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` + diff --git a/backlog/tasks/task-296 - Suppress-crash-notification-when-closing-launcher-managed-video.md b/backlog/tasks/task-296 - Suppress-crash-notification-when-closing-launcher-managed-video.md new file mode 100644 index 00000000..b2a18f89 --- /dev/null +++ b/backlog/tasks/task-296 - Suppress-crash-notification-when-closing-launcher-managed-video.md @@ -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 + + +Investigate and fix regression where closing a running mpv video causes SubMiner/Electron service crash notification (`/SubMiner has encountered a fatal error and was closed.`). Not present on origin/main/v0.12.0 path. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-297 - Fix-subtitle-annotation-color-priority-after-typography-cleanup.md b/backlog/tasks/task-297 - Fix-subtitle-annotation-color-priority-after-typography-cleanup.md new file mode 100644 index 00000000..52e503bc --- /dev/null +++ b/backlog/tasks/task-297 - Fix-subtitle-annotation-color-priority-after-typography-cleanup.md @@ -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 + + +Known-word and frequency subtitle token colors should keep their configured priority after annotation CSS stopped using JLPT underlines. + + +## Acceptance Criteria + +- [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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-298 - Exclude-kana-grammar-helper-merges-like-ことに-from-subtitle-annotations.md b/backlog/tasks/task-298 - Exclude-kana-grammar-helper-merges-like-ことに-from-subtitle-annotations.md new file mode 100644 index 00000000..329d063b --- /dev/null +++ b/backlog/tasks/task-298 - Exclude-kana-grammar-helper-merges-like-ことに-from-subtitle-annotations.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md b/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md new file mode 100644 index 00000000..b610bd54 --- /dev/null +++ b/backlog/tasks/task-299 - Force-audio-replacement-during-manual-subtitle-mining.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/backlog/tasks/task-300 - Fix-transparent-subtitle-hover-background-config.md b/backlog/tasks/task-300 - Fix-transparent-subtitle-hover-background-config.md new file mode 100644 index 00000000..53cdc834 --- /dev/null +++ b/backlog/tasks/task-300 - Fix-transparent-subtitle-hover-background-config.md @@ -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 + + +User reports setting subtitleStyle.hoverTokenBackgroundColor to transparent still renders default hover background in overlay subtitles. + + +## Acceptance Criteria + +- [x] #1 Transparent hoverTokenBackgroundColor is accepted by config resolution. +- [x] #2 Renderer applies transparent hover token background instead of falling back to default. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-301 - Fix-launcher-managed-video-close-leaving-background-app-alive.md b/backlog/tasks/task-301 - Fix-launcher-managed-video-close-leaving-background-app-alive.md new file mode 100644 index 00000000..8127275b --- /dev/null +++ b/backlog/tasks/task-301 - Fix-launcher-managed-video-close-leaving-background-app-alive.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/backlog/tasks/task-302 - Add-visible-hover-affordance-for-annotated-subtitle-tokens.md b/backlog/tasks/task-302 - Add-visible-hover-affordance-for-annotated-subtitle-tokens.md new file mode 100644 index 00000000..41fe8e9a --- /dev/null +++ b/backlog/tasks/task-302 - Add-visible-hover-affordance-for-annotated-subtitle-tokens.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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`. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-303 - Update-tray-menu-help-action.md b/backlog/tasks/task-303 - Update-tray-menu-help-action.md new file mode 100644 index 00000000..6b8148a6 --- /dev/null +++ b/backlog/tasks/task-303 - Update-tray-menu-help-action.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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`. + + +## Final Summary + + +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/`. + diff --git a/changes/291-character-dictionary-selection.md b/changes/291-character-dictionary-selection.md new file mode 100644 index 00000000..72410716 --- /dev/null +++ b/changes/291-character-dictionary-selection.md @@ -0,0 +1,5 @@ +type: added +area: dictionary + +- Added CLI and in-app AniList selection for character dictionary mismatches, with series-scoped overrides that replace stale wrong-title entries in the merged dictionary. +- Added launcher support through `subminer dictionary --candidates` and `subminer dictionary --select`, plus a default `Ctrl+Alt+A` shortcut for the in-app selector. diff --git a/changes/292-linux-multi-copy.md b/changes/292-linux-multi-copy.md new file mode 100644 index 00000000..38612c51 --- /dev/null +++ b/changes/292-linux-multi-copy.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Linux multi-line subtitle copy timing out after the prompt by letting the overlay handle the follow-up digit locally. diff --git a/changes/293-interjection-annotation-filter.md b/changes/293-interjection-annotation-filter.md new file mode 100644 index 00000000..36e14b67 --- /dev/null +++ b/changes/293-interjection-annotation-filter.md @@ -0,0 +1,4 @@ +type: fixed +area: tokenizer + +- Stopped standalone `あ` interjections from receiving subtitle annotation metadata such as N+1, JLPT, and frequency highlighting when POS tags are unavailable. diff --git a/changes/295-primary-subtitle-bar-toggle.md b/changes/295-primary-subtitle-bar-toggle.md new file mode 100644 index 00000000..50ccca36 --- /dev/null +++ b/changes/295-primary-subtitle-bar-toggle.md @@ -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. diff --git a/changes/296-mpv-close-crash-notification.md b/changes/296-mpv-close-crash-notification.md new file mode 100644 index 00000000..e29dd8e3 --- /dev/null +++ b/changes/296-mpv-close-crash-notification.md @@ -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. diff --git a/changes/297-subtitle-annotation-colors.md b/changes/297-subtitle-annotation-colors.md new file mode 100644 index 00000000..81b59c13 --- /dev/null +++ b/changes/297-subtitle-annotation-colors.md @@ -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. diff --git a/changes/298-kana-grammar-helper-annotations.md b/changes/298-kana-grammar-helper-annotations.md new file mode 100644 index 00000000..09155993 --- /dev/null +++ b/changes/298-kana-grammar-helper-annotations.md @@ -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. diff --git a/changes/299-manual-subtitle-audio-overwrite.md b/changes/299-manual-subtitle-audio-overwrite.md new file mode 100644 index 00000000..6f5c309e --- /dev/null +++ b/changes/299-manual-subtitle-audio-overwrite.md @@ -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. diff --git a/changes/300-transparent-hover-background.md b/changes/300-transparent-hover-background.md new file mode 100644 index 00000000..e4fbd8d9 --- /dev/null +++ b/changes/300-transparent-hover-background.md @@ -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. diff --git a/changes/301-managed-playback-exit.md b/changes/301-managed-playback-exit.md new file mode 100644 index 00000000..78863b69 --- /dev/null +++ b/changes/301-managed-playback-exit.md @@ -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. diff --git a/changes/302-annotated-hover-affordance.md b/changes/302-annotated-hover-affordance.md new file mode 100644 index 00000000..bea4d19c --- /dev/null +++ b/changes/302-annotated-hover-affordance.md @@ -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. diff --git a/changes/303-tray-help-menu.md b/changes/303-tray-help-menu.md new file mode 100644 index 00000000..11a2a32c --- /dev/null +++ b/changes/303-tray-help-menu.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 92474731..7632da1e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -172,6 +172,7 @@ "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. + "openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index cf1deb9a..7a1c05f8 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index a95b4718..46f91472 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -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. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 26d82277..0bfdfb15 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -28,9 +28,9 @@ Character dictionary sync is disabled by default. To turn it on: "enabled": true, "accessToken": "your-token", "characterDictionary": { - "enabled": true - } - } + "enabled": true, + }, + }, } ``` @@ -47,33 +47,35 @@ If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for: **Spacing and combination:** + - Full name with space: 須々木 心一 - Combined form: 須々木心一 - Family name alone: 須々木 - Given name alone: 心一 **Middle-dot removal** (common in katakana foreign names): + - ア・リ・ス → アリス (combined), plus individual segments **Honorific suffixes** — each base name is expanded with 15 common suffixes: -| Honorific | Reading | -| --- | --- | -| さん | さん | -| 様 | さま | -| 先生 | せんせい | -| 先輩 | せんぱい | -| 後輩 | こうはい | -| 氏 | し | -| 君 | くん | -| くん | くん | -| ちゃん | ちゃん | -| たん | たん | -| 坊 | ぼう | -| 殿 | どの | -| 博士 | はかせ | -| 社長 | しゃちょう | -| 部長 | ぶちょう | +| Honorific | Reading | +| --------- | ---------- | +| さん | さん | +| 様 | さま | +| 先生 | せんせい | +| 先輩 | せんぱい | +| 後輩 | こうはい | +| 氏 | し | +| 君 | くん | +| くん | くん | +| ちゃん | ちゃん | +| たん | たん | +| 坊 | ぼう | +| 殿 | どの | +| 博士 | はかせ | +| 社長 | しゃちょう | +| 部長 | ぶちょう | **Romanized names** — names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text. @@ -92,10 +94,10 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting, **Key settings:** -| Option | Default | Description | -| --- | --- | --- | -| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | -| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | +| Option | Default | Description | +| -------------------------------- | --------- | ---------------------------------- | +| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | +| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | ## Dictionary Entries @@ -117,10 +119,10 @@ The three collapsible sections can be configured to start open or closed: "collapsibleSections": { "description": false, "characterInformation": false, - "voicedBy": false - } - } - } + "voicedBy": false, + }, + }, + }, } ``` @@ -143,7 +145,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine { "activeMediaIds": [170942, 163134, 154587], "mergedRevision": "a1b2c3d4e5f6", - "mergedDictionaryTitle": "SubMiner Character Dictionary" + "mergedDictionaryTitle": "SubMiner Character Dictionary", } ``` @@ -163,6 +165,29 @@ SubMiner.AppImage --dictionary This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots. +## Correcting AniList Matches + +SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series. + +Use the in-app selector or CLI to pin the correct AniList media for the whole series: + +```bash +# List candidate AniList matches for a file +subminer dictionary --candidates "/path/to/episode.mkv" + +# Save the correct AniList media ID for that series +subminer dictionary --select 21355 "/path/to/episode.mkv" + +# Equivalent direct app flags +SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv" +SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv" + +# Open the in-app selector from the running app +subminer app --open-character-dictionary +``` + +Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. + ## File Structure All character dictionary data lives under `{userData}/character-dictionaries/`: @@ -174,6 +199,7 @@ character-dictionaries/ anilist-163134.json merged.zip # Active merged dictionary (imported into Yomitan) auto-sync-state.json # Tracks active media IDs and revision + anilist-overrides.json # Manual series-to-AniList overrides img/ m170942-c12345.jpg # Character portrait m170942-va67890.jpg # Voice actor portrait @@ -194,16 +220,16 @@ merged.zip ## Configuration Reference -| Option | Default | Description | -| --- | --- | --- | -| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | -| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | -| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | -| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | -| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | -| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | -| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles | -| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | +| Option | Default | Description | +| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- | +| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | +| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | +| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | +| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | +| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | +| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | +| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles | +| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | ## Reference Implementation @@ -211,14 +237,14 @@ SubMiner's character dictionary builder is inspired by the [Japanese Character N The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences: -| | SubMiner | Reference Implementation | -| --- | --- | --- | -| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service | -| **Data sources** | AniList only | AniList + VNDB | -| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI | -| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export | -| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) | -| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh | +| | SubMiner | Reference Implementation | +| ---------------------- | -------------------------------------------- | ------------------------------------- | +| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service | +| **Data sources** | AniList only | AniList + VNDB | +| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI | +| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export | +| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) | +| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh | If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out. @@ -226,7 +252,7 @@ If you work with visual novels or want a standalone dictionary generator indepen - **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. -- **Wrong characters showing:** The merged dictionary includes your `maxLoaded` most recent titles. If you're seeing names from a previous show, they'll rotate out once you watch enough new titles to push it past the limit. +- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`. - **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this. - **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index ea28a111..6fe134ef 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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) | @@ -535,6 +535,7 @@ See `config.example.jsonc` for detailed configuration options. "mineSentence": "CommandOrControl+S", "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", + "openCharacterDictionary": "CommandOrControl+Alt+A", "openRuntimeOptions": "CommandOrControl+Shift+O", "openSessionHelp": "CommandOrControl+Shift+H", "openControllerSelect": "Alt+C", @@ -559,10 +560,11 @@ 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"`) | | `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"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | @@ -689,6 +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+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | @@ -854,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. diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index c6813b07..0b8cb7a8 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -69,40 +69,42 @@ subminer stats -b # start background stats daemon ## Subcommands -| Subcommand | Purpose | -| ---------------------------- | ---------------------------------------------------------- | -| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | -| `subminer stats` | Start stats server and open immersion dashboard in browser | -| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | -| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | -| `subminer doctor` | Dependency + config + socket diagnostics | -| `subminer config path` | Print active config file path | -| `subminer config show` | Print active config contents | -| `subminer mpv status` | Check mpv socket readiness | -| `subminer mpv socket` | Print active socket path | -| `subminer mpv idle` | Launch detached idle mpv instance | -| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | -| `subminer texthooker` | Launch texthooker-only mode | -| `subminer app` | Pass arguments directly to SubMiner binary | +| Subcommand | Purpose | +| ------------------------------------------ | ------------------------------------------------------------------ | +| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | +| `subminer stats` | Start stats server and open immersion dashboard in browser | +| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | +| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | +| `subminer doctor` | Dependency + config + socket diagnostics | +| `subminer config path` | Print active config file path | +| `subminer config show` | Print active config contents | +| `subminer mpv status` | Check mpv socket readiness | +| `subminer mpv socket` | Print active socket path | +| `subminer mpv idle` | Launch detached idle mpv instance | +| `subminer dictionary ` | Generate character dictionary ZIP from file/dir target | +| `subminer dictionary --candidates ` | List AniList candidate matches for character dictionary correction | +| `subminer dictionary --select ` | Pin an AniList media ID for that target series | +| `subminer texthooker` | Launch texthooker-only mode | +| `subminer app` | Pass arguments directly to SubMiner binary | Use `subminer -h` for command-specific help. ## Options -| Flag | Description | -| --------------------- | --------------------------------------------------- | -| `-d, --directory` | Video search directory (default: cwd) | -| `-r, --recursive` | Search directories recursively | -| `-R, --rofi` | Use rofi instead of fzf | -| `--setup` | Open first-run setup popup manually | -| `--start` | Explicitly start overlay after mpv launches | -| `-S, --start-overlay` | Explicitly start overlay after mpv launches | -| `-T, --no-texthooker` | Disable texthooker server | -| `-p, --profile` | mpv profile name (no default; omitted unless set) | -| `-a, --args` | Pass additional mpv arguments as a quoted string | -| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) | -| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | -| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | +| Flag | Description | +| --------------------- | -------------------------------------------------------------------- | +| `-d, --directory` | Video search directory (default: cwd) | +| `-r, --recursive` | Search directories recursively | +| `-R, --rofi` | Use rofi instead of fzf | +| `--setup` | Open first-run setup popup manually | +| `--start` | Explicitly start overlay after mpv launches | +| `-S, --start-overlay` | Explicitly start overlay after mpv launches | +| `-T, --no-texthooker` | Disable texthooker server | +| `-p, --profile` | mpv profile name (no default; omitted unless set) | +| `-a, --args` | Pass additional mpv arguments as a quoted string | +| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) | +| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | +| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index e2905c2c..5154a470 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -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 | diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index 8b24476c..46703204 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -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: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 92474731..7632da1e 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -172,6 +172,7 @@ "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. + "openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index a79ee7a0..b37a0e8c 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -35,27 +35,28 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu These control playback and subtitle display. They require overlay window focus. -| Shortcut | Action | -| -------------------- | -------------------------------------------------- | -| `Space` | Toggle mpv pause | -| `J` | Cycle primary subtitle track | -| `Shift+J` | Cycle secondary subtitle track | +| 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 | -| `ArrowRight` | Seek forward 5 seconds | -| `ArrowLeft` | Seek backward 5 seconds | -| `ArrowUp` | Seek forward 60 seconds | -| `ArrowDown` | Seek backward 60 seconds | -| `Shift+H` | Jump to previous subtitle | -| `Shift+L` | Jump to next subtitle | -| `Shift+[` | Shift subtitle delay to previous subtitle cue | -| `Shift+]` | Shift subtitle delay to next subtitle cue | -| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | -| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | -| `Q` | Quit mpv | -| `Ctrl+W` | Quit mpv | -| `Right-click` | Toggle pause (outside subtitle area) | -| `Right-click + drag` | Reposition subtitles (on subtitle area) | -| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | +| `ArrowRight` | Seek forward 5 seconds | +| `ArrowLeft` | Seek backward 5 seconds | +| `ArrowUp` | Seek forward 60 seconds | +| `ArrowDown` | Seek backward 60 seconds | +| `Shift+H` | Jump to previous subtitle | +| `Shift+L` | Jump to next subtitle | +| `Shift+[` | Shift subtitle delay to previous subtitle cue | +| `Shift+]` | Shift subtitle delay to next subtitle cue | +| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | +| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | +| `Q` | Quit mpv | +| `Ctrl+W` | Quit mpv | +| `Right-click` | Toggle pause (outside subtitle area) | +| `Right-click + drag` | Reposition subtitles (on subtitle area) | +| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. @@ -63,16 +64,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ------------------------------ | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | -| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ----------------------------------- | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. @@ -99,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 @@ -115,7 +120,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel ## Customizing Shortcuts -All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut. +All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut. ```jsonc { diff --git a/docs-site/usage.md b/docs-site/usage.md index 0d446d4a..6f8b9f9e 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -100,6 +100,8 @@ subminer mpv socket # Print active mpv socket path subminer mpv status # Exit 0 if socket is ready, else exit 1 subminer mpv idle # Launch detached idle mpv with SubMiner defaults subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import) +subminer dictionary --candidates /path/to/file.mkv +subminer dictionary --select 21355 /path/to/file.mkv subminer texthooker # Launch texthooker-only mode subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) @@ -112,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 @@ -124,6 +127,9 @@ SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-s SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow) SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime +SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series +SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series +SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector SubMiner.AppImage --help # Show all options ``` @@ -166,6 +172,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan - `subminer config`: config helpers (`path`, `show`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer dictionary `: generates a Yomitan-importable character dictionary ZIP from a file/directory target. +- Use `subminer dictionary --candidates ` and `subminer dictionary --select ` to correct AniList character-dictionary matches for a whole series. - `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). - `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage. - Subcommand help pages are available (for example `subminer jellyfin -h`). @@ -321,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. diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 4a912e8d..4247aa74 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -190,7 +190,43 @@ test('dictionary command forwards --dictionary and target path to app binary', ( }); assert.equal(handled, true); - assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]); + assert.deepEqual(forwarded, [['--start', '--dictionary', '--dictionary-target', '/tmp/anime']]); +}); + +test('dictionary command forwards candidate and selection modes to app binary', () => { + const candidatesContext = createContext(); + candidatesContext.args.dictionary = true; + candidatesContext.args.dictionaryCandidates = true; + candidatesContext.args.dictionaryTarget = '/tmp/anime.mkv'; + const selectContext = createContext(); + selectContext.args.dictionary = true; + selectContext.args.dictionarySelect = true; + selectContext.args.dictionaryAnilistId = 21355; + selectContext.args.dictionaryTarget = '/tmp/anime.mkv'; + const forwarded: string[][] = []; + + runDictionaryCommand(candidatesContext, { + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + runDictionaryCommand(selectContext, { + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + + assert.deepEqual(forwarded, [ + ['--start', '--dictionary-candidates', '--dictionary-target', '/tmp/anime.mkv'], + [ + '--start', + '--dictionary-select', + '--dictionary-anilist-id', + '21355', + '--dictionary-target', + '/tmp/anime.mkv', + ], + ]); }); test('dictionary command returns after app handoff starts', () => { diff --git a/launcher/commands/dictionary-command.ts b/launcher/commands/dictionary-command.ts index 02b0785a..210ced30 100644 --- a/launcher/commands/dictionary-command.ts +++ b/launcher/commands/dictionary-command.ts @@ -18,7 +18,20 @@ export function runDictionaryCommand( return false; } - const forwarded = ['--dictionary']; + const forwarded = [ + '--start', + args.dictionaryCandidates + ? '--dictionary-candidates' + : args.dictionarySelect + ? '--dictionary-select' + : '--dictionary', + ]; + if (args.dictionarySelect) { + if (!args.dictionaryAnilistId) { + throw new Error('Dictionary selection requires an AniList media ID.'); + } + forwarded.push('--dictionary-anilist-id', String(args.dictionaryAnilistId)); + } if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) { forwarded.push('--dictionary-target', args.dictionaryTarget); } diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index e3359023..2b703e23 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -44,6 +44,8 @@ function createContext(): LauncherCommandContext { jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, doctor: false, doctorRefreshKnownWords: false, diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 71cbddc9..1cf6f801 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -129,6 +129,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { dictionaryTriggered: false, dictionaryTarget: null, dictionaryLogLevel: null, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: null, statsTriggered: false, statsBackground: false, statsStop: false, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 23e36eeb..27e24c18 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -89,6 +89,14 @@ function parseDictionaryTarget(value: string): string { return resolved; } +function parseDictionaryAnilistId(value: string): number { + const id = Number.parseInt(value, 10); + if (!Number.isSafeInteger(id) || id <= 0 || String(id) !== value.trim()) { + fail(`Invalid AniList media ID: ${value}`); + } + return id; +} + export function createDefaultArgs( launcherConfig: LauncherYoutubeSubgenConfig, mpvConfig: LauncherMpvConfig = {}, @@ -138,6 +146,8 @@ export function createDefaultArgs( jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, statsBackground: false, statsStop: false, @@ -214,6 +224,11 @@ export function applyRootOptionsToArgs( export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void { if (invocations.dictionaryTriggered) parsed.dictionary = true; + if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true; + if (invocations.dictionarySelect) parsed.dictionarySelect = true; + if (invocations.dictionaryAnilistId) { + parsed.dictionaryAnilistId = parseDictionaryAnilistId(invocations.dictionaryAnilistId); + } if (invocations.statsTriggered) parsed.stats = true; if (invocations.statsBackground) parsed.statsBackground = true; if (invocations.statsStop) parsed.statsStop = true; @@ -222,6 +237,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true; if (invocations.dictionaryTarget) { parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget); + } else if ( + invocations.dictionaryTriggered && + !invocations.dictionaryCandidates && + !invocations.dictionarySelect + ) { + fail('Dictionary target path is required.'); } if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index eb6de93f..b92a31c5 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -27,6 +27,9 @@ export interface CliInvocations { dictionaryTriggered: boolean; dictionaryTarget: string | null; dictionaryLogLevel: string | null; + dictionaryCandidates: boolean; + dictionarySelect: boolean; + dictionaryAnilistId: string | null; statsTriggered: boolean; statsBackground: boolean; statsStop: boolean; @@ -136,6 +139,9 @@ export function parseCliPrograms( let dictionaryTriggered = false; let dictionaryTarget: string | null = null; let dictionaryLogLevel: string | null = null; + let dictionaryCandidates = false; + let dictionarySelect = false; + let dictionaryAnilistId: string | null = null; let statsTriggered = false; let statsBackground = false; let statsStop = false; @@ -207,13 +213,23 @@ export function parseCliPrograms( commandProgram .command('dictionary') .alias('dict') - .description('Generate character dictionary ZIP from a file or directory target') - .argument('', 'Video file path or anime directory path') + .description('Generate or correct character dictionary AniList matches') + .argument('[target]', 'Video file path or anime directory path') + .option('--candidates', 'List AniList candidates for a character dictionary target') + .option('--select ', 'Pin an AniList media ID for the target series') .option('--log-level ', 'Log level') - .action((target: string, options: Record) => { + .action((target: string | undefined, options: Record) => { + 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; + dictionaryTarget = target ?? null; dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + dictionaryCandidates = options.candidates === true; + dictionarySelect = hasSelect; + dictionaryAnilistId = hasSelect ? selectValue : null; }); commandProgram @@ -338,6 +354,9 @@ export function parseCliPrograms( dictionaryTriggered, dictionaryTarget, dictionaryLogLevel, + dictionaryCandidates, + dictionarySelect, + dictionaryAnilistId, statsTriggered, statsBackground, statsStop, diff --git a/launcher/main.test.ts b/launcher/main.test.ts index cf7b1f19..4fc6d7a7 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -464,7 +464,40 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co assert.equal(result.status, 0); assert.equal( fs.readFileSync(capturePath, 'utf8'), - `--dictionary\n--dictionary-target\n${targetPath}\n`, + `--start\n--dictionary\n--dictionary-target\n${targetPath}\n`, + ); + }); +}); + +test('dictionary command forwards manual AniList selection modes to app command path', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + 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', + ); + fs.chmodSync(appPath, 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_CAPTURE: capturePath, + }; + const targetPath = path.join(root, 'anime.mkv'); + fs.writeFileSync(targetPath, ''); + + assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0); + assert.equal( + fs.readFileSync(capturePath, 'utf8'), + `--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`, + ); + assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0); + assert.equal( + fs.readFileSync(capturePath, 'utf8'), + `--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`, ); }); }); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 1486e6a6..7d28f4c9 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -415,6 +415,8 @@ function makeArgs(overrides: Partial = {}): Args { jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, doctor: false, doctorRefreshKnownWords: false, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index ca133949..7e7ebb02 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -99,6 +99,25 @@ test('parseArgs maps dictionary command and log-level override', () => { assert.equal(parsed.logLevel, 'debug'); }); +test('parseArgs maps dictionary candidate lookup and manual selection', () => { + const candidateParsed = parseArgs(['dictionary', '--candidates', '.'], 'subminer', {}); + assert.equal(candidateParsed.dictionaryCandidates, true); + assert.equal(candidateParsed.dictionaryTarget, process.cwd()); + + const selectParsed = parseArgs(['dictionary', '--select', '21355', '.'], 'subminer', {}); + assert.equal(selectParsed.dictionarySelect, true); + assert.equal(selectParsed.dictionaryAnilistId, 21355); + 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', {}); diff --git a/launcher/types.ts b/launcher/types.ts index 64edbe27..588e12d7 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -121,6 +121,9 @@ export interface Args { jellyfinPlay: boolean; jellyfinDiscovery: boolean; dictionary: boolean; + dictionaryCandidates: boolean; + dictionarySelect: boolean; + dictionaryAnilistId?: number; stats: boolean; statsBackground?: boolean; statsStop?: boolean; diff --git a/package.json b/package.json index 9239dbb7..a189db1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index eac8dc08..2ffd5de2 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -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) diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index e3046187..79c6225c 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -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, diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index 92cbabb5..6514aa6d 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -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() diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 7f64814a..00b50c0e 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -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 diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts new file mode 100644 index 00000000..fe1bea29 --- /dev/null +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -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[0]; + +function createManualUpdateService(overrides: Partial = {}): { + service: CardCreationService; + updatedFields: Record[]; + mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>; + storedMedia: string[]; +} { + const updatedFields: Record[] = []; + 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], + ); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 375a6773..2d2fc536 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -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; diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5047f5ca..ff2e457e 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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,6 +214,22 @@ 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', + ]); + assert.equal(dictionaryCandidates.dictionaryCandidates, true); + assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv'); + assert.equal(hasExplicitCommand(dictionaryCandidates), true); + assert.equal(shouldStartApp(dictionaryCandidates), true); + + const dictionarySelect = parseArgs(['--dictionary-select', '--dictionary-anilist-id', '21355']); + assert.equal(dictionarySelect.dictionarySelect, true); + assert.equal(dictionarySelect.dictionaryAnilistId, 21355); + assert.equal(hasExplicitCommand(dictionarySelect), true); + assert.equal(shouldStartApp(dictionarySelect), true); + const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']); assert.equal(toggleStatsOverlay.toggleStatsOverlay, true); assert.equal(hasExplicitCommand(toggleStatsOverlay), true); @@ -320,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); diff --git a/src/cli/args.ts b/src/cli/args.ts index 2df3e301..aee216a9 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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; @@ -28,6 +30,7 @@ export interface CliArgs { toggleSubtitleSidebar: boolean; openRuntimeOptions: boolean; openSessionHelp: boolean; + openCharacterDictionary: boolean; openControllerSelect: boolean; openControllerDebug: boolean; openJimaku: boolean; @@ -46,6 +49,9 @@ export interface CliArgs { anilistSetup: boolean; anilistRetryQueue: boolean; dictionary: boolean; + dictionaryCandidates: boolean; + dictionarySelect: boolean; + dictionaryAnilistId?: number; dictionaryTarget?: string; stats: boolean; statsBackground?: boolean; @@ -94,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: [], @@ -102,6 +109,7 @@ export function parseArgs(argv: string[]): CliArgs { stop: false, toggle: false, toggleVisibleOverlay: false, + togglePrimarySubtitleBar: false, settings: false, setup: false, show: false, @@ -122,6 +130,7 @@ export function parseArgs(argv: string[]): CliArgs { toggleSubtitleSidebar: false, openRuntimeOptions: false, openSessionHelp: false, + openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, @@ -136,6 +145,8 @@ export function parseArgs(argv: string[]): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, stats: false, statsBackground: false, statsStop: false, @@ -192,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]; @@ -212,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; @@ -232,6 +245,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-session-help') args.openSessionHelp = true; + else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true; else if (arg === '--open-controller-select') args.openControllerSelect = true; else if (arg === '--open-controller-debug') args.openControllerDebug = true; else if (arg === '--open-jimaku') args.openJimaku = true; @@ -270,7 +284,15 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--anilist-setup') args.anilistSetup = true; else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true; else if (arg === '--dictionary') args.dictionary = true; - else if (arg.startsWith('--dictionary-target=')) { + else if (arg === '--dictionary-candidates') args.dictionaryCandidates = true; + else if (arg === '--dictionary-select') args.dictionarySelect = true; + else if (arg.startsWith('--dictionary-anilist-id=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value; + } else if (arg === '--dictionary-anilist-id') { + const value = Number(readValue(argv[i + 1])); + if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value; + } else if (arg.startsWith('--dictionary-target=')) { const value = arg.split('=', 2)[1]; if (value) args.dictionaryTarget = value; } else if (arg === '--dictionary-target') { @@ -440,6 +462,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.stop || args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.settings || args.setup || args.show || @@ -460,6 +483,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -477,6 +501,8 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.anilistSetup || args.anilistRetryQueue || args.dictionary || + args.dictionaryCandidates || + args.dictionarySelect || args.stats || args.jellyfin || args.jellyfinLogin || @@ -507,6 +533,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.stop && !args.toggle && !args.toggleVisibleOverlay && + !args.togglePrimarySubtitleBar && !args.settings && !args.setup && !args.show && @@ -527,6 +554,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && + !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -544,6 +572,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.anilistSetup && !args.anilistRetryQueue && !args.dictionary && + !args.dictionaryCandidates && + !args.dictionarySelect && !args.stats && !args.jellyfin && !args.jellyfinLogin && @@ -569,6 +599,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.launchMpv || args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.settings || args.setup || args.copySubtitle || @@ -585,6 +616,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -598,6 +630,8 @@ export function shouldStartApp(args: CliArgs): boolean { args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.dictionary || + args.dictionaryCandidates || + args.dictionarySelect || args.stats || args.jellyfin || args.jellyfinPlay || @@ -619,6 +653,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.stop && !args.toggle && !args.toggleVisibleOverlay && + !args.togglePrimarySubtitleBar && !args.show && !args.hide && !args.setup && @@ -638,6 +673,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && + !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -655,6 +691,8 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.anilistSetup && !args.anilistRetryQueue && !args.dictionary && + !args.dictionaryCandidates && + !args.dictionarySelect && !args.stats && !args.jellyfin && !args.jellyfinLogin && @@ -679,6 +717,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { return ( args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.show || args.hide || args.showVisibleOverlay || @@ -696,6 +735,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.markAudioCard || args.openRuntimeOptions || args.openSessionHelp || + args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || diff --git a/src/cli/help.ts b/src/cli/help.ts index ad31ff7e..83e70992 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 @@ -38,6 +39,7 @@ ${B}Mining${R} --toggle-subtitle-sidebar Toggle subtitle sidebar panel --open-runtime-options Open runtime options palette --open-session-help Open session help modal + --open-character-dictionary Open character dictionary anime selection modal --open-controller-select Open controller select modal --open-controller-debug Open controller debug modal @@ -47,6 +49,9 @@ ${B}AniList${R} --anilist-logout Clear stored AniList token --anilist-retry-queue Retry next queued update --dictionary Generate character dictionary ZIP for current anime + --dictionary-candidates Show character dictionary AniList candidates + --dictionary-select Save manual character dictionary AniList selection + --dictionary-anilist-id ${D}ID${R} AniList media ID for --dictionary-select --dictionary-target ${D}PATH${R} Override dictionary source path (file or directory) ${B}Jellyfin${R} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 85f59272..1cd057a4 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -50,6 +50,8 @@ test('loads defaults when config is missing', () => { assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, true); + assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); + assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.updateIntervalMs, 3_000); @@ -435,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( diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 30507bcf..b590cbd7 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -86,6 +86,7 @@ export const CORE_DEFAULT_CONFIG: Pick< multiCopyTimeoutMs: 3000, toggleSecondarySub: 'CommandOrControl+Shift+V', markAudioCard: 'CommandOrControl+Shift+A', + openCharacterDictionary: 'CommandOrControl+Alt+A', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', openSessionHelp: 'CommandOrControl+Shift+H', diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 6d356b82..79985f7a 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'mineSentenceMultiple', 'toggleSecondarySub', 'markAudioCard', + 'openCharacterDictionary', 'openRuntimeOptions', 'openJimaku', ] as const; diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 2bf032c8..26612743 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -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()).', ); diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index 37c5c5af..7d741a6b 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -76,6 +76,32 @@ test('guessAnilistMediaInfo joins multi-part guessit titles', async () => { }); }); +test('guessAnilistMediaInfo preserves useful guessit alternative title for ambiguous Re ZERO filenames', async () => { + const result = await guessAnilistMediaInfo( + '/tmp/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv', + null, + { + runGuessit: async () => + JSON.stringify({ + title: 'Re', + alternative_title: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + }), + }, + ); + + assert.deepEqual(result, { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + source: 'guessit', + }); +}); + test('updateAnilistPostWatchProgress updates progress when behind', async () => { const originalFetch = globalThis.fetch; let call = 0; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 9d704e05..2a2238c4 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -7,6 +7,8 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; export interface AnilistMediaGuess { title: string; + alternativeTitle?: string; + year?: number; season: number | null; episode: number | null; source: 'guessit' | 'fallback'; @@ -131,6 +133,20 @@ function firstPositiveInteger(value: unknown): number | null { return null; } +function firstYear(value: unknown): number | undefined { + const candidate = firstPositiveInteger(value); + if (candidate === null) return undefined; + return candidate >= 1900 && candidate <= 2200 ? candidate : undefined; +} + +function buildGuessitTitle(title: string, alternativeTitle: string | null): string { + if (!alternativeTitle) return title; + if (title.length <= 3) { + return `${title} ${alternativeTitle}`.replace(/\s+/g, ' ').trim(); + } + return title; +} + function normalizeTitle(text: string): string { return text.trim().toLowerCase().replace(/\s+/g, ' '); } @@ -215,10 +231,19 @@ export async function guessAnilistMediaInfo( const stdout = await deps.runGuessit(guessitTarget); const parsed = JSON.parse(stdout) as Record; const title = readGuessitTitle(parsed.title); + const alternativeTitle = readGuessitTitle(parsed.alternative_title); const episode = firstPositiveInteger(parsed.episode); const season = firstPositiveInteger(parsed.season); + const year = firstYear(parsed.year); if (title) { - return { title, season, episode, source: 'guessit' }; + return { + title: buildGuessitTitle(title, alternativeTitle), + ...(alternativeTitle ? { alternativeTitle } : {}), + ...(year ? { year } : {}), + season, + episode, + source: 'guessit', + }; } } catch { // Ignore guessit failures and fall back to internal parser. diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 545d12e9..7da4bf96 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -6,12 +6,14 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle'; function makeArgs(overrides: Partial = {}): 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, @@ -37,6 +39,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -48,6 +51,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 2bfed141..6aa62953 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command'; function makeArgs(overrides: Partial = {}): CliArgs { return { background: false, + managedPlayback: false, start: false, launchMpv: false, launchMpvTargets: [], @@ -34,11 +35,13 @@ function makeArgs(overrides: Partial = {}): CliArgs { refreshKnownWords: false, openRuntimeOptions: false, openSessionHelp: false, + openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + togglePrimarySubtitleBar: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -50,6 +53,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, @@ -115,6 +121,9 @@ function createDeps(overrides: Partial = {}) { toggleVisibleOverlay: () => { calls.push('toggleVisibleOverlay'); }, + togglePrimarySubtitleBar: () => { + calls.push('togglePrimarySubtitleBar'); + }, openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, @@ -199,6 +208,19 @@ function createDeps(overrides: Partial = {}) { mediaTitle: 'Test', entryCount: 10, }), + getCharacterDictionarySelection: async () => ({ + seriesKey: 'test', + guessTitle: 'Test', + current: { id: 1, title: 'Test', episodes: 12 }, + override: null, + candidates: [{ id: 1, title: 'Test', episodes: 12 }], + }), + setCharacterDictionarySelection: async () => ({ + ok: true, + seriesKey: 'test', + selected: { id: 1, title: 'Test', episodes: 12 }, + staleMediaIds: [], + }), runStatsCommand: async () => { calls.push('runStatsCommand'); }, @@ -516,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 }, @@ -624,6 +647,105 @@ test('handleCliCommand forwards --dictionary-target to dictionary runtime', asyn assert.equal(receivedTarget, '/tmp/example-video.mkv'); }); +test('handleCliCommand lists character dictionary AniList candidates', async () => { + const { calls, deps } = createDeps({ + getCharacterDictionarySelection: async (targetPath?: string) => { + calls.push(`getCharacterDictionarySelection:${targetPath ?? ''}`); + return { + seriesKey: 're-zero-starting-life-in-another-world-2016', + guessTitle: 'Re ZERO, Starting Life in Another World', + current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: null }, + override: null, + candidates: [ + { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }, + { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 }, + ], + }; + }, + }); + + handleCliCommand( + makeArgs({ dictionaryCandidates: true, dictionaryTarget: '/tmp/re-zero.mkv' }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.includes('getCharacterDictionarySelection:/tmp/re-zero.mkv')); + assert.ok( + calls.includes( + 'log:Character dictionary series key: re-zero-starting-life-in-another-world-2016', + ), + ); + assert.ok( + calls.includes('log:Candidate: 21355 - Re:ZERO -Starting Life in Another World- (25 episodes)'), + ); +}); + +test('handleCliCommand sets character dictionary manual AniList selection', async () => { + const { calls, deps } = createDeps({ + setCharacterDictionarySelection: async (request) => { + calls.push(`setCharacterDictionarySelection:${request.mediaId}:${request.targetPath ?? ''}`); + return { + ok: true, + seriesKey: 're-zero-starting-life-in-another-world-2016', + selected: { + id: request.mediaId, + title: 'Re:ZERO -Starting Life in Another World-', + episodes: 25, + }, + staleMediaIds: [10607], + }; + }, + }); + + handleCliCommand( + makeArgs({ + dictionarySelect: true, + dictionaryAnilistId: 21355, + dictionaryTarget: '/tmp/re-zero.mkv', + }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(calls.includes('setCharacterDictionarySelection:21355:/tmp/re-zero.mkv')); + assert.ok( + calls.includes( + 'log:Character dictionary override saved: re-zero-starting-life-in-another-world-2016 -> 21355 - Re:ZERO -Starting Life in Another World-', + ), + ); +}); + +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> = [ { start: true }, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 85ba0168..ae249d8a 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -1,6 +1,27 @@ import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; import type { SessionActionDispatchRequest } from '../../types/runtime'; +export type CharacterDictionaryCandidate = { + id: number; + title: string; + episodes: number | null; +}; + +export type CharacterDictionarySelectionSnapshot = { + seriesKey: string; + guessTitle: string | null; + current: CharacterDictionaryCandidate | null; + override: CharacterDictionaryCandidate | null; + candidates: CharacterDictionaryCandidate[]; +}; + +export type CharacterDictionarySelectionResult = { + ok: boolean; + seriesKey: string; + selected: CharacterDictionaryCandidate; + staleMediaIds: number[]; +}; + export interface CliCommandServiceDeps { setLogLevel?: (level: NonNullable) => void; getMpvSocketPath: () => string; @@ -19,6 +40,7 @@ export interface CliCommandServiceDeps { isOverlayRuntimeInitialized: () => boolean; initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; openYomitanSettingsDelayed: (delayMs: number) => void; setVisibleOverlayVisible: (visible: boolean) => void; @@ -64,6 +86,13 @@ export interface CliCommandServiceDeps { mediaTitle: string; entryCount: number; }>; + getCharacterDictionarySelection: ( + targetPath?: string, + ) => Promise; + setCharacterDictionarySelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: (request: { @@ -110,6 +139,7 @@ interface OverlayCliRuntime { isInitialized: () => boolean; initialize: () => void; toggleVisible: () => void; + togglePrimarySubtitleBar: () => void; setVisible: (visible: boolean) => void; } @@ -162,6 +192,11 @@ export interface CliCommandDepsRuntimeOptions { mediaTitle: string; entryCount: number; }>; + getSelection: (targetPath?: string) => Promise; + setSelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; }; jellyfin: { openSetup: () => void; @@ -211,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(() => { @@ -237,6 +273,8 @@ export function createCliCommandDepsRuntime( getAnilistQueueStatus: options.anilist.getQueueStatus, retryAnilistQueue: options.anilist.retryQueueNow, generateCharacterDictionary: options.dictionary.generate, + getCharacterDictionarySelection: options.dictionary.getSelection, + setCharacterDictionarySelection: options.dictionary.setSelection, runStatsCommand: options.jellyfin.runStatsCommand, runJellyfinCommand: options.jellyfin.runCommand, runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, @@ -267,6 +305,14 @@ function runAsyncWithOsd( }); } +function formatCandidate(candidate: CharacterDictionaryCandidate): string { + const episodeLabel = + typeof candidate.episodes === 'number' && candidate.episodes > 0 + ? `${candidate.episodes} episodes` + : 'episodes unknown'; + return `${candidate.id} - ${candidate.title} (${episodeLabel})`; +} + export function handleCliCommand( args: CliArgs, source: CliCommandSource = 'initial', @@ -326,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.'); @@ -411,6 +459,12 @@ export function handleCliCommand( 'openSessionHelp', 'Open session help failed', ); + } else if (args.openCharacterDictionary) { + dispatchCliSessionAction( + { actionId: 'openCharacterDictionary' }, + 'openCharacterDictionary', + 'Open character dictionary failed', + ); } else if (args.openControllerSelect) { dispatchCliSessionAction( { actionId: 'openControllerSelect' }, @@ -546,6 +600,75 @@ export function handleCliCommand( deps.stopApp(); } }); + } else if (args.dictionaryCandidates) { + const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); + deps + .getCharacterDictionarySelection(args.dictionaryTarget) + .then((selection) => { + deps.log(`Character dictionary series key: ${selection.seriesKey}`); + if (selection.guessTitle) { + deps.log(`Guess: ${selection.guessTitle}`); + } + if (selection.current) { + deps.log(`Current match: ${formatCandidate(selection.current)}`); + } + if (selection.override) { + deps.log(`Manual override: ${formatCandidate(selection.override)}`); + } + for (const candidate of selection.candidates) { + deps.log(`Candidate: ${formatCandidate(candidate)}`); + } + }) + .catch((error) => { + deps.error('getCharacterDictionarySelection failed:', error); + deps.warn( + `Character dictionary candidate lookup failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }) + .finally(() => { + if (shouldStopAfterRun) { + deps.stopApp(); + } + }); + } else if (args.dictionarySelect) { + const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); + if (!args.dictionaryAnilistId) { + deps.warn('--dictionary-select requires --dictionary-anilist-id .'); + if (shouldStopAfterRun) deps.stopApp(); + return; + } + deps + .setCharacterDictionarySelection({ + targetPath: args.dictionaryTarget, + 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}`, + ); + if (result.staleMediaIds.length > 0) { + deps.log(`Removed stale AniList IDs: ${result.staleMediaIds.join(', ')}`); + } + }) + .catch((error) => { + deps.error('setCharacterDictionarySelection failed:', error); + deps.warn( + `Character dictionary override failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }) + .finally(() => { + if (shouldStopAfterRun) { + deps.stopApp(); + } + }); } else if (args.stats) { void deps.runStatsCommand(args, source); } else if (args.anilistRetryQueue) { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 049cadec..3d9be47d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -1023,3 +1023,58 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy await saveHandler!({}, { preferredGamepadId: 12 }); }, /Invalid controller preference payload/); }); + +test('registerIpcHandlers exposes character dictionary selection handlers', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: number[] = []; + + registerIpcHandlers( + createRegisterIpcDeps({ + getCharacterDictionarySelection: async () => ({ + seriesKey: 're-zero-starting-life-in-another-world-2016', + guessTitle: 'Re ZERO, Starting Life in Another World', + current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 }, + override: null, + candidates: [ + { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }, + ], + }), + setCharacterDictionarySelection: async (mediaId) => { + calls.push(mediaId); + return { + ok: true, + seriesKey: 're-zero-starting-life-in-another-world-2016', + selected: { + id: mediaId, + title: 'Re:ZERO -Starting Life in Another World-', + episodes: 25, + }, + staleMediaIds: [10607], + }; + }, + }), + registrar, + ); + + const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection); + const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection); + + assert.deepEqual(await getHandler!({}), { + seriesKey: 're-zero-starting-life-in-another-world-2016', + guessTitle: 'Re ZERO, Starting Life in Another World', + current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 }, + override: null, + candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }], + }); + assert.deepEqual(await setHandler!({}, 0), { + ok: false, + message: 'Invalid AniList media ID.', + }); + assert.deepEqual(await setHandler!({}, 21355), { + ok: true, + seriesKey: 're-zero-starting-life-in-another-world-2016', + selected: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }, + staleMediaIds: [10607], + }); + assert.deepEqual(calls, [21355]); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 26f443ca..690bda20 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -90,6 +90,8 @@ export interface IpcServiceDeps { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + getCharacterDictionarySelection?: () => Promise; + setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions { openAnilistSetup: () => void; getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; + getCharacterDictionarySelection?: () => Promise; + setCharacterDictionarySelection?: (mediaId: number) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService openAnilistSetup: options.openAnilistSetup, getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, + getCharacterDictionarySelection: + options.getCharacterDictionarySelection ?? + (async () => ({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })), + setCharacterDictionarySelection: + options.setCharacterDictionarySelection ?? + (async () => ({ + ok: false, + seriesKey: '', + selected: { id: 0, title: '', episodes: null }, + staleMediaIds: [], + })), appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, @@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return await deps.retryAnilistQueueNow(); }); + ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => { + return await (deps.getCharacterDictionarySelection?.() ?? + Promise.resolve({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })); + }); + + ipc.handle( + IPC_CHANNELS.request.setCharacterDictionarySelection, + async (_event, mediaId: unknown) => { + if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) { + return { ok: false, message: 'Invalid AniList media ID.' }; + } + return await (deps.setCharacterDictionarySelection?.(mediaId as number) ?? + Promise.resolve({ + ok: false, + message: 'Character dictionary selection unavailable.', + })); + }, + ); + ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { return deps.appendClipboardVideoToQueue(); }); diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 9259b70c..5c3756b7 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -19,6 +19,7 @@ function createDeps(overrides: Partial = {}): { commands: unknown[]; mediaPath: string; restored: number; + quitRequested: number; }; } { const state = { @@ -28,6 +29,7 @@ function createDeps(overrides: Partial = {}): { commands: [] as unknown[], mediaPath: '', restored: 0, + quitRequested: 0, }; const metrics: MpvSubtitleRenderMetrics = { subPos: 100, @@ -102,6 +104,10 @@ function createDeps(overrides: Partial = {}): { 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 () => { diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 6a8ac979..b79dbbd9 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index f4a388ec..e9b5b2a5 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -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()); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 54a76677..7e5df317 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps { isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | 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?.(), }; } diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index f2d80b78..fca5d0dc 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -25,6 +25,7 @@ function makeShortcuts(overrides: Partial = {}): Configured multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -45,6 +46,9 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptions: () => { calls.push('openRuntimeOptions'); }, + openCharacterDictionary: () => { + calls.push('openCharacterDictionary'); + }, openJimaku: () => { calls.push('openJimaku'); }, @@ -135,12 +139,11 @@ test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', asyn } }); -test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => { +test('runOverlayShortcutLocalFallback dispatches matching single-step actions', () => { const handled: string[] = []; const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; const shortcuts = makeShortcuts({ - copySubtitleMultiple: 'Ctrl+M', - multiCopyTimeoutMs: 4321, + copySubtitle: 'Ctrl+M', }); const result = runOverlayShortcutLocalFallback( @@ -155,6 +158,7 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', }, { openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -169,10 +173,63 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', ); assert.equal(result, true); - assert.deepEqual(handled, ['copySubtitleMultiple:4321']); + assert.deepEqual(handled, ['copySubtitle']); assert.deepEqual(matched, [{ accelerator: 'Ctrl+M', allowWhenRegistered: false }]); }); +test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for renderer handling', () => { + const handled: string[] = []; + const shortcuts = makeShortcuts({ + copySubtitleMultiple: 'Ctrl+M', + mineSentenceMultiple: 'Ctrl+N', + multiCopyTimeoutMs: 4321, + }); + + const copyResult = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator) => accelerator === 'Ctrl+M', + { + openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), + openJimaku: () => handled.push('openJimaku'), + markAudioCard: () => handled.push('markAudioCard'), + copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), + copySubtitle: () => handled.push('copySubtitle'), + toggleSecondarySub: () => handled.push('toggleSecondarySub'), + updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'), + triggerFieldGrouping: () => handled.push('triggerFieldGrouping'), + triggerSubsync: () => handled.push('triggerSubsync'), + mineSentence: () => handled.push('mineSentence'), + mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`), + }, + ); + + const mineResult = runOverlayShortcutLocalFallback( + {} as Electron.Input, + shortcuts, + (_input, accelerator) => accelerator === 'Ctrl+N', + { + openRuntimeOptions: () => handled.push('openRuntimeOptions'), + openCharacterDictionary: () => handled.push('openCharacterDictionary'), + openJimaku: () => handled.push('openJimaku'), + markAudioCard: () => handled.push('markAudioCard'), + copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), + copySubtitle: () => handled.push('copySubtitle'), + toggleSecondarySub: () => handled.push('toggleSecondarySub'), + updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'), + triggerFieldGrouping: () => handled.push('triggerFieldGrouping'), + triggerSubsync: () => handled.push('triggerSubsync'), + mineSentence: () => handled.push('mineSentence'), + mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`), + }, + ); + + assert.equal(copyResult, false); + assert.equal(mineResult, false); + assert.deepEqual(handled, []); +}); + test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => { const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; const shortcuts = makeShortcuts({ @@ -191,6 +248,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s }, { openRuntimeOptions: () => {}, + openCharacterDictionary: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -226,6 +284,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', }, { openRuntimeOptions: () => {}, + openCharacterDictionary: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -253,6 +312,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', () openRuntimeOptions: () => { called = true; }, + openCharacterDictionary: () => { + called = true; + }, openJimaku: () => { called = true; }, @@ -335,6 +397,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured', mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -361,6 +424,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index 09d6e06d..94556fed 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -6,6 +6,7 @@ const logger = createLogger('main:overlay-shortcut-handler'); export interface OverlayShortcutFallbackHandlers { openRuntimeOptions: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => void; copySubtitleMultiple: (timeoutMs: number) => void; @@ -21,6 +22,7 @@ export interface OverlayShortcutFallbackHandlers { export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -95,6 +97,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim openRuntimeOptions: () => { deps.openRuntimeOptions(); }, + openCharacterDictionary: () => { + deps.openCharacterDictionary(); + }, openJimaku: () => { deps.openJimaku(); }, @@ -102,6 +107,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim const fallbackHandlers: OverlayShortcutFallbackHandlers = { openRuntimeOptions: overlayHandlers.openRuntimeOptions, + openCharacterDictionary: overlayHandlers.openCharacterDictionary, openJimaku: overlayHandlers.openJimaku, markAudioCard: overlayHandlers.markAudioCard, copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, @@ -134,6 +140,12 @@ export function runOverlayShortcutLocalFallback( handlers.openRuntimeOptions(); }, }, + { + accelerator: shortcuts.openCharacterDictionary, + run: () => { + handlers.openCharacterDictionary(); + }, + }, { accelerator: shortcuts.openJimaku, run: () => { @@ -147,12 +159,6 @@ export function runOverlayShortcutLocalFallback( handlers.markAudioCard(); }, }, - { - accelerator: shortcuts.copySubtitleMultiple, - run: () => { - handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, { accelerator: shortcuts.copySubtitle, run: () => { @@ -188,12 +194,6 @@ export function runOverlayShortcutLocalFallback( handlers.mineSentence(); }, }, - { - accelerator: shortcuts.mineSentenceMultiple, - run: () => { - handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs); - }, - }, ]; for (const action of actions) { diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 72d30e7e..87598b98 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -20,6 +20,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -42,6 +43,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -61,6 +63,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -82,6 +85,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, + openCharacterDictionary: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index cfd8375a..5a873896 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -10,6 +10,7 @@ export interface OverlayShortcutHandlers { mineSentenceMultiple: (timeoutMs: number) => void; toggleSecondarySub: () => void; markAudioCard: () => void; + openCharacterDictionary: () => void; openRuntimeOptions: () => void; openJimaku: () => void; } @@ -31,6 +32,7 @@ const OVERLAY_SHORTCUT_KEYS: Array Promise; openRuntimeOptionsPalette: () => void; openSessionHelp: () => void; + openCharacterDictionary: () => void; openControllerSelect: () => void; openControllerDebug: () => void; openJimaku: () => void; @@ -85,6 +86,9 @@ export async function dispatchSessionAction( case 'openSessionHelp': deps.openSessionHelp(); return; + case 'openCharacterDictionary': + deps.openCharacterDictionary(); + return; case 'openControllerSelect': deps.openControllerSelect(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index cceea9b1..5cf95501 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -18,6 +18,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index d3956407..855e0e75 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -43,6 +43,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' }, { key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' }, { key: 'markAudioCard', actionId: 'markAudioCard' }, + { key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' }, diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 188b6c94..ccf88848 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -6,12 +6,14 @@ import { CliArgs } from '../../cli/args'; function makeArgs(overrides: Partial = {}): 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, @@ -37,6 +39,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -48,6 +51,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 670f0055..8a42f858 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -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( diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index 7683ea66..cb78c244 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -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: 'は', @@ -812,3 +825,69 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc assert.equal(result[0]?.frequencyRank, undefined); 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({ + surface: 'あ', + headword: 'あ', + reading: 'あ', + partOfSpeech: PartOfSpeech.other, + pos1: '', + pos2: '', + startPos: 0, + endPos: 1, + isKnown: true, + isNPlusOneTarget: true, + frequencyRank: 522, + jlptLevel: 'N5', + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === 'あ', + getJlptLevel: (text) => (text === 'あ' ? 'N5' : null), + }), + { minSentenceWordsForNPlusOne: 1 }, + ); + + assert.equal(result[0]?.surface, 'あ'); + assert.equal(result[0]?.headword, 'あ'); + assert.equal(result[0]?.reading, 'あ'); + assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.isNPlusOneTarget, false); + assert.equal(result[0]?.frequencyRank, undefined); + assert.equal(result[0]?.jlptLevel, undefined); +}); diff --git a/src/core/services/tokenizer/subtitle-annotation-filter.ts b/src/core/services/tokenizer/subtitle-annotation-filter.ts index 8b2a3d49..4537a962 100644 --- a/src/core/services/tokenizer/subtitle-annotation-filter.ts +++ b/src/core/services/tokenizer/subtitle-annotation-filter.ts @@ -14,6 +14,7 @@ const KATAKANA_CODEPOINT_START = 0x30a1; const KATAKANA_CODEPOINT_END = 0x30f6; const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([ + 'あ', 'ああ', 'ええ', 'うう', @@ -70,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; @@ -251,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, @@ -334,6 +361,10 @@ export function shouldExcludeTokenFromSubtitleAnnotations( return true; } + if (isKanaOnlyNonIndependentNounHelperMerge(token)) { + return true; + } + if (isExcludedTrailingParticleMergedToken(token)) { return true; } diff --git a/src/core/utils/shortcut-config.test.ts b/src/core/utils/shortcut-config.test.ts index 9ac47a7e..f3f3119d 100644 --- a/src/core/utils/shortcut-config.test.ts +++ b/src/core/utils/shortcut-config.test.ts @@ -66,6 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { shortcuts: { mineSentence: 'KeyQ', openRuntimeOptions: 'Digit9', + openCharacterDictionary: 'Ctrl+Shift+KeyA', }, }; @@ -73,4 +74,5 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { assert.equal(resolved.mineSentence, 'Q'); assert.equal(resolved.openRuntimeOptions, '9'); + assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A'); }); diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index 25807dc6..0c9e3c01 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -12,6 +12,7 @@ export interface ConfiguredShortcuts { multiCopyTimeoutMs: number; toggleSecondarySub: string | null | undefined; markAudioCard: string | null | undefined; + openCharacterDictionary: string | null | undefined; openRuntimeOptions: string | null | undefined; openJimaku: string | null | undefined; openSessionHelp: string | null | undefined; @@ -76,6 +77,9 @@ export function resolveConfiguredShortcuts( ? null : (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard), ), + openCharacterDictionary: normalizeShortcut( + config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary, + ), openRuntimeOptions: normalizeShortcut( config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions, ), diff --git a/src/main.ts b/src/main.ts index dc45d5c3..85dd19da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -458,6 +458,7 @@ import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open'; import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; +import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; @@ -533,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') { @@ -1492,6 +1494,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, + openCharacterDictionary: () => { + openCharacterDictionaryOverlay(); + }, openJimaku: () => { openJimakuOverlay(); }, @@ -2290,6 +2295,14 @@ function openSessionHelpOverlay(): void { ); } +function openCharacterDictionaryOverlay(): void { + openOverlayHostedModalWithOsd( + openCharacterDictionaryModalRuntime, + 'Character dictionary overlay unavailable.', + 'Failed to open character dictionary overlay.', + ); +} + function openControllerSelectOverlay(): void { openOverlayHostedModalWithOsd( openControllerSelectModalRuntime, @@ -3810,6 +3823,8 @@ const { setReconnectTimer: (timer: ReturnType | null) => { appState.reconnectTimer = timer; }, + shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true, + requestAppQuit: () => requestAppQuit(), }, updateMpvSubtitleRenderMetricsMainDeps: { getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, @@ -4333,6 +4348,10 @@ function toggleSubtitleSidebar(): void { broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); } +function togglePrimarySubtitleBar(): void { + broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle); +} + async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } @@ -4622,6 +4641,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJimaku: () => openJimakuOverlay(), openSessionHelp: () => openSessionHelpOverlay(), + openCharacterDictionary: () => openCharacterDictionaryOverlay(), openControllerSelect: () => openControllerSelectOverlay(), openControllerDebug: () => openControllerDebugOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), @@ -4842,6 +4862,18 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ openAnilistSetup: () => openAnilistSetupWindow(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), + getCharacterDictionarySelection: () => + characterDictionaryRuntime.getManualSelectionSnapshot(), + 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, @@ -4898,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(), @@ -4923,6 +4956,16 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ } return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); }, + getCharacterDictionarySelection: async (targetPath?: string) => + characterDictionaryRuntime.getManualSelectionSnapshot(targetPath), + 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), @@ -5096,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', diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 3480500d..74cee0bb 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -25,12 +25,21 @@ import { } from './character-dictionary-runtime/constants'; import { downloadCharacterImage, + fetchAniListMediaCandidateById, fetchCharactersForMedia, resolveAniListMediaIdFromGuess, + searchAniListMediaCandidates, } from './character-dictionary-runtime/fetch'; +import { + buildCharacterDictionarySeriesKey, + createCharacterDictionaryManualSelectionStore, +} from './character-dictionary-runtime/manual-selection'; import type { + AniListMediaCandidate, CharacterDictionaryBuildResult, CharacterDictionaryGenerateOptions, + CharacterDictionaryManualSelectionResult, + CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryRuntimeDeps, CharacterDictionarySnapshotImage, CharacterDictionarySnapshotProgress, @@ -136,6 +145,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar progress?: CharacterDictionarySnapshotProgressCallbacks, ) => Promise; buildMergedDictionary: (mediaIds: number[]) => Promise; + getManualSelectionSnapshot: ( + targetPath?: string, + ) => Promise; + setManualSelection: (request: { + targetPath?: string; + mediaId: number; + }) => Promise; generateForCurrentMedia: ( targetPath?: string, options?: CharacterDictionaryGenerateOptions, @@ -144,26 +160,56 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const sleepMs = deps.sleep ?? sleep; const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false); + const manualSelectionStore = createCharacterDictionaryManualSelectionStore({ + userDataPath: deps.userDataPath, + }); + + const createAniListRequestSlot = (): (() => Promise) => { + let hasAniListRequest = false; + return async (): Promise => { + if (!hasAniListRequest) { + hasAniListRequest = true; + return; + } + await sleepMs(ANILIST_REQUEST_DELAY_MS); + }; + }; + + const resolveGuessInput = ( + targetPath?: string, + ): { mediaPath: string | null; mediaTitle: string | null } => { + const dictionaryTarget = targetPath?.trim() || ''; + return dictionaryTarget.length > 0 + ? resolveDictionaryGuessInputs(dictionaryTarget) + : { + mediaPath: deps.getCurrentMediaPath(), + mediaTitle: deps.getCurrentMediaTitle(), + }; + }; + + const guessCurrentMedia = async (targetPath?: string) => { + const guessInput = resolveGuessInput(targetPath); + const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); + const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle); + if (!guessed || !guessed.title.trim()) { + throw new Error('Unable to resolve current anime from media path/title.'); + } + return { + guessed, + seriesKey: buildCharacterDictionarySeriesKey({ + mediaPath: mediaPathForGuess, + mediaTitle: guessInput.mediaTitle, + guess: guessed, + }), + }; + }; const resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, ): Promise => { deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation'); - const dictionaryTarget = targetPath?.trim() || ''; - const guessInput = - dictionaryTarget.length > 0 - ? resolveDictionaryGuessInputs(dictionaryTarget) - : { - mediaPath: deps.getCurrentMediaPath(), - mediaTitle: deps.getCurrentMediaTitle(), - }; - const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath); - const mediaTitle = guessInput.mediaTitle; - const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle); - if (!guessed || !guessed.title.trim()) { - throw new Error('Unable to resolve current anime from media path/title.'); - } + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); deps.logInfo?.( `[dictionary] current anime guess: ${guessed.title.trim()}${ typeof guessed.episode === 'number' && guessed.episode > 0 @@ -171,6 +217,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar : '' }`, ); + const override = await manualSelectionStore.getOverride(seriesKey); + if (override) { + deps.logInfo?.( + `[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`, + ); + return { + id: override.mediaId, + title: override.mediaTitle, + staleMediaIds: override.staleMediaIds, + }; + } const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`); return resolved; @@ -283,25 +340,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar targetPath?: string, progress?: CharacterDictionarySnapshotProgressCallbacks, ) => { - let hasAniListRequest = false; - const waitForAniListRequestSlot = async (): Promise => { - if (!hasAniListRequest) { - hasAniListRequest = true; - return; - } - await sleepMs(ANILIST_REQUEST_DELAY_MS); - }; + const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); progress?.onChecking?.({ mediaId: resolvedMedia.id, mediaTitle: resolvedMedia.title, }); - return getOrCreateSnapshot( + const snapshot = await getOrCreateSnapshot( resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot, progress, ); + return { + ...snapshot, + staleMediaIds: resolvedMedia.staleMediaIds, + }; }, buildMergedDictionary: async (mediaIds: number[]) => { const normalizedMediaIds = normalizeMergedMediaIds(mediaIds); @@ -341,18 +395,58 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar entryCount, }; }, + getManualSelectionSnapshot: async (targetPath?: string) => { + const waitForAniListRequestSlot = createAniListRequestSlot(); + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); + const [candidates, override] = await Promise.all([ + searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot), + manualSelectionStore.getOverride(seriesKey), + ]); + const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot) + .then( + (entry): AniListMediaCandidate => ({ + id: entry.id, + title: entry.title, + episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null, + }), + ) + .catch(() => null); + return { + seriesKey, + guessTitle: guessed.title, + current, + override: override + ? { id: override.mediaId, title: override.mediaTitle, episodes: null } + : null, + candidates, + }; + }, + setManualSelection: async ({ targetPath, mediaId }) => { + const waitForAniListRequestSlot = createAniListRequestSlot(); + const { guessed, seriesKey } = await guessCurrentMedia(targetPath); + const [selected, current] = await Promise.all([ + fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot), + resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null), + ]); + const staleMediaIds = current && current.id !== selected.id ? [current.id] : []; + await manualSelectionStore.setOverride({ + seriesKey, + mediaId: selected.id, + mediaTitle: selected.title, + staleMediaIds, + }); + return { + ok: true, + seriesKey, + selected, + staleMediaIds, + }; + }, generateForCurrentMedia: async ( targetPath?: string, _options?: CharacterDictionaryGenerateOptions, ) => { - let hasAniListRequest = false; - const waitForAniListRequestSlot = async (): Promise => { - if (!hasAniListRequest) { - hasAniListRequest = true; - return; - } - await sleepMs(ANILIST_REQUEST_DELAY_MS); - }; + const waitForAniListRequestSlot = createAniListRequestSlot(); const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot); const snapshot = await getOrCreateSnapshot( resolvedMedia.id, diff --git a/src/main/character-dictionary-runtime/fetch.test.ts b/src/main/character-dictionary-runtime/fetch.test.ts new file mode 100644 index 00000000..09bb7718 --- /dev/null +++ b/src/main/character-dictionary-runtime/fetch.test.ts @@ -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'); + } + } +}); diff --git a/src/main/character-dictionary-runtime/fetch.ts b/src/main/character-dictionary-runtime/fetch.ts index 61ba2454..17e02565 100644 --- a/src/main/character-dictionary-runtime/fetch.ts +++ b/src/main/character-dictionary-runtime/fetch.ts @@ -1,6 +1,7 @@ import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; import { ANILIST_GRAPHQL_URL } from './constants'; import type { + AniListMediaCandidate, CharacterDictionaryRole, CharacterRecord, ResolvedAniListMedia, @@ -123,6 +124,30 @@ function pickAniListSearchResult( }; } +function toAniListMediaCandidate( + entry: { + id: number; + episodes?: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }, + 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() || + normalizedFallback, + episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null, + }; +} + async function fetchAniList( query: string, variables: Record, @@ -208,6 +233,69 @@ export async function resolveAniListMediaIdFromGuess( return resolved; } +export async function searchAniListMediaCandidates( + title: string, + beforeRequest?: () => Promise, +): Promise { + const data = await fetchAniList( + ` + query($search: String!) { + Page(perPage: 10) { + media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) { + id + episodes + title { + romaji + english + native + } + } + } + } + `, + { search: title }, + beforeRequest, + ); + return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title)); +} + +export async function fetchAniListMediaCandidateById( + mediaId: number, + beforeRequest?: () => Promise, +): Promise { + const data = await fetchAniList<{ + Media?: { + id: number; + episodes?: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + } | null; + }>( + ` + query($id: Int!) { + Media(id: $id, type: ANIME) { + id + episodes + title { + romaji + english + native + } + } + } + `, + { id: mediaId }, + beforeRequest, + ); + if (!data.Media) { + throw new Error(`AniList media ${mediaId} not found.`); + } + return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`); +} + export async function fetchCharactersForMedia( mediaId: number, beforeRequest?: () => Promise, diff --git a/src/main/character-dictionary-runtime/manual-selection.test.ts b/src/main/character-dictionary-runtime/manual-selection.test.ts new file mode 100644 index 00000000..e7901b40 --- /dev/null +++ b/src/main/character-dictionary-runtime/manual-selection.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + buildCharacterDictionarySeriesKey, + createCharacterDictionaryManualSelectionStore, +} from './manual-selection'; + +const REZERO_EP1 = + '/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv'; +const REZERO_EP2 = + '/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-')); +} + +test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => { + const key = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP1, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + source: 'guessit', + }, + }); + + assert.equal(key, 're-zero-starting-life-in-another-world-2016'); +}); + +test('manual selection store persists overrides and matches later episodes in the same series', async () => { + const userDataPath = makeTempDir(); + const store = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const firstKey = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP1, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 1, + source: 'guessit', + }, + }); + await store.setOverride({ + seriesKey: firstKey, + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + staleMediaIds: [10607], + }); + + const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath }); + const secondKey = buildCharacterDictionarySeriesKey({ + mediaPath: REZERO_EP2, + mediaTitle: null, + guess: { + title: 'Re ZERO, Starting Life in Another World', + alternativeTitle: 'ZERO, Starting Life in Another World', + year: 2016, + season: 1, + episode: 2, + source: 'guessit', + }, + }); + + assert.equal(secondKey, firstKey); + assert.deepEqual(await reloaded.getOverride(secondKey), { + seriesKey: firstKey, + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + staleMediaIds: [10607], + }); +}); diff --git a/src/main/character-dictionary-runtime/manual-selection.ts b/src/main/character-dictionary-runtime/manual-selection.ts new file mode 100644 index 00000000..c2d29587 --- /dev/null +++ b/src/main/character-dictionary-runtime/manual-selection.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater'; +import { ensureDir } from '../../shared/fs-utils'; + +export type CharacterDictionaryManualSelection = { + seriesKey: string; + mediaId: number; + mediaTitle: string; + staleMediaIds: number[]; +}; + +type ManualSelectionStoreFile = { + overrides?: CharacterDictionaryManualSelection[]; +}; + +function normalizeManualMediaId(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const mediaId = Math.floor(value); + return mediaId > 0 ? mediaId : null; +} + +function normalizeSeriesKeyPart(value: string): string { + return value + .normalize('NFKD') + .replace(/[':]/g, '') + .replace(/&/g, ' and ') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-') + .toLowerCase(); +} + +function dedupeNumbers(values: number[]): number[] { + const seen = new Set(); + const result: number[] = []; + for (const value of values) { + const normalized = normalizeManualMediaId(value); + if (normalized === null || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Partial; + const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : ''; + const mediaId = normalizeManualMediaId(raw.mediaId); + const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : ''; + if (!seriesKey || mediaId === null || !mediaTitle) return null; + return { + seriesKey, + mediaId, + mediaTitle, + staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []), + }; +} + +function readOverrides(filePath: string): CharacterDictionaryManualSelection[] { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile; + if (!Array.isArray(parsed.overrides)) return []; + const byKey = new Map(); + for (const value of parsed.overrides) { + const normalized = normalizeOverride(value); + if (normalized) byKey.set(normalized.seriesKey, normalized); + } + return [...byKey.values()]; + } catch { + return []; + } +} + +function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8'); +} + +export function buildCharacterDictionarySeriesKey(input: { + mediaPath: string | null; + mediaTitle: string | null; + guess: AnilistMediaGuess | null; +}): string { + const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || ''; + const sourceTitle = + guessedTitle || + (input.mediaTitle && input.mediaTitle.trim()) || + (input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) || + 'unknown'; + const withoutEpisode = sourceTitle + .replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ') + .replace(/\bepisode\s+\d+\b/gi, ' ') + .trim(); + const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown'; + return input.guess?.year ? `${base}-${input.guess.year}` : base; +} + +export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) { + const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json'); + + return { + getOverride: async (seriesKey: string): Promise => { + return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null; + }, + setOverride: async (selection: CharacterDictionaryManualSelection): Promise => { + const normalized = normalizeOverride(selection); + if (!normalized) { + throw new Error('Invalid character dictionary manual selection.'); + } + const remaining = readOverrides(filePath).filter( + (entry) => entry.seriesKey !== normalized.seriesKey, + ); + writeOverrides(filePath, [...remaining, normalized]); + }, + }; +} diff --git a/src/main/character-dictionary-runtime/types.ts b/src/main/character-dictionary-runtime/types.ts index 81b057d4..a12e20b2 100644 --- a/src/main/character-dictionary-runtime/types.ts +++ b/src/main/character-dictionary-runtime/types.ts @@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = { entryCount: number; fromCache: boolean; updatedAt: number; + staleMediaIds?: number[]; }; export type CharacterDictionarySnapshotProgress = { @@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = { entryCount: number; }; +export type AniListMediaCandidate = { + id: number; + title: string; + episodes: number | null; +}; + +export type CharacterDictionaryManualSelectionSnapshot = { + seriesKey: string; + guessTitle: string | null; + current: AniListMediaCandidate | null; + override: AniListMediaCandidate | null; + candidates: AniListMediaCandidate[]; +}; + +export type CharacterDictionaryManualSelectionResult = { + ok: boolean; + seriesKey: string; + selected: AniListMediaCandidate; + staleMediaIds: number[]; +}; + export interface CharacterDictionaryRuntimeDeps { userDataPath: string; getCurrentMediaPath: () => string | null; @@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps { export type ResolvedAniListMedia = { id: number; title: string; + staleMediaIds?: number[]; }; diff --git a/src/main/character-dictionary-selection.test.ts b/src/main/character-dictionary-selection.test.ts new file mode 100644 index 00000000..aa511c49 --- /dev/null +++ b/src/main/character-dictionary-selection.test.ts @@ -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); +}); diff --git a/src/main/character-dictionary-selection.ts b/src/main/character-dictionary-selection.ts new file mode 100644 index 00000000..94b5c55a --- /dev/null +++ b/src/main/character-dictionary-selection.ts @@ -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; + resetAnilistMediaGuessState: () => void; + runSyncNow: () => Promise; + warn: (message: string, error?: unknown) => void; +}; + +export async function applyCharacterDictionarySelection( + request: CharacterDictionarySelectionRequest, + deps: CharacterDictionarySelectionDeps, +): Promise { + 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; +} diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 4cec9dc0..4d687ea1 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -37,6 +38,8 @@ export interface CliCommandRuntimeServiceContext { getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus']; retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate']; + getCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['getSelection']; + setCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['setSelection']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; @@ -81,6 +84,7 @@ function createCliCommandDepsFromContext( isInitialized: context.isOverlayInitialized, initialize: context.initializeOverlay, toggleVisible: context.toggleVisibleOverlay, + togglePrimarySubtitleBar: context.togglePrimarySubtitleBar, setVisible: context.setVisibleOverlay, }, mining: { @@ -103,6 +107,8 @@ function createCliCommandDepsFromContext( }, dictionary: { generate: context.generateCharacterDictionary, + getSelection: context.getCharacterDictionarySelection, + setSelection: context.setCharacterDictionarySelection, }, jellyfin: { openSetup: context.openJellyfinSetup, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 0e9646f1..24672ea8 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -94,6 +94,8 @@ export interface MainIpcRuntimeServiceDepsParams { openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; + getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; @@ -147,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: { @@ -169,6 +172,8 @@ export interface CliCommandRuntimeServiceDepsParams { }; dictionary: { generate: CliCommandDepsRuntimeOptions['dictionary']['generate']; + getSelection: CliCommandDepsRuntimeOptions['dictionary']['getSelection']; + setSelection: CliCommandDepsRuntimeOptions['dictionary']['setSelection']; }; jellyfin: { openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup']; @@ -258,6 +263,8 @@ export function createMainIpcRuntimeServiceDeps( openAnilistSetup: params.openAnilistSetup, getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, + getCharacterDictionarySelection: params.getCharacterDictionarySelection, + setCharacterDictionarySelection: params.setCharacterDictionarySelection, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, @@ -319,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: { @@ -341,6 +349,8 @@ export function createCliCommandRuntimeServiceDeps( }, dictionary: { generate: params.dictionary.generate, + getSelection: params.dictionary.getSelection, + setSelection: params.dictionary.setSelection, }, jellyfin: { openSetup: params.jellyfin.openSetup, diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 4b4e3ae7..1e1d7d7a 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput { isOverlayShortcutContextActive?: () => boolean; showMpvOsd: (text: string) => void; openRuntimeOptionsPalette: () => void; + openCharacterDictionary: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService( openRuntimeOptions: () => { input.openRuntimeOptionsPalette(); }, + openCharacterDictionary: () => { + input.openCharacterDictionary(); + }, openJimaku: () => { input.openJimaku(); }, diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index f4b1d486..e6722568 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -459,6 +459,66 @@ test('auto sync keeps revisited media retained when a new title is added afterwa assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']); }); +test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => { + const userDataPath = makeTempDir(); + const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); + fs.mkdirSync(dictionariesDir, { recursive: true }); + fs.writeFileSync( + path.join(dictionariesDir, 'auto-sync-state.json'), + JSON.stringify( + { + activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'], + mergedRevision: 'old', + mergedDictionaryTitle: 'SubMiner Character Dictionary', + }, + null, + 2, + ), + ); + const builtMediaIds: number[][] = []; + const runtime = createCharacterDictionaryAutoSyncRuntimeService({ + userDataPath, + getConfig: () => ({ + enabled: true, + maxLoaded: 5, + profileScope: 'all', + }), + getOrCreateCurrentSnapshot: async () => ({ + mediaId: 21355, + mediaTitle: 'Re:ZERO -Starting Life in Another World-', + entryCount: 120, + fromCache: false, + updatedAt: 99, + staleMediaIds: [10607], + }), + buildMergedDictionary: async (mediaIds) => { + builtMediaIds.push([...mediaIds]); + return { + zipPath: path.join(dictionariesDir, 'merged.zip'), + revision: `rev-${mediaIds.join('-')}`, + dictionaryTitle: 'SubMiner Character Dictionary', + entryCount: 200, + }; + }, + getYomitanDictionaryInfo: async () => [], + importYomitanDictionary: async () => true, + deleteYomitanDictionary: async () => true, + upsertYomitanDictionarySettings: async () => false, + now: () => 1, + }); + + await runtime.runSyncNow(); + + assert.deepEqual(builtMediaIds, [[21355, 130298]]); + const state = JSON.parse( + fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'), + ) as { activeMediaIds: string[] }; + assert.deepEqual(state.activeMediaIds, [ + '21355 - Re:ZERO -Starting Life in Another World-', + '130298 - The Eminence in Shadow', + ]); +}); + test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => { const userDataPath = makeTempDir(); const dictionariesDir = path.join(userDataPath, 'character-dictionaries'); diff --git a/src/main/runtime/character-dictionary-auto-sync.ts b/src/main/runtime/character-dictionary-auto-sync.ts index d65e1d6a..08474893 100644 --- a/src/main/runtime/character-dictionary-auto-sync.ts +++ b/src/main/runtime/character-dictionary-auto-sync.ts @@ -271,12 +271,19 @@ export function createCharacterDictionaryAutoSyncRuntimeService( currentMediaId = snapshot.mediaId; currentMediaTitle = snapshot.mediaTitle; const state = readAutoSyncState(statePath); + const staleMediaIds = new Set( + (snapshot.staleMediaIds ?? []) + .map((mediaId) => normalizeMediaId(mediaId)) + .filter((mediaId): mediaId is number => mediaId !== null), + ); const nextActiveMediaIds = [ { mediaId: snapshot.mediaId, label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle), }, - ...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId), + ...state.activeMediaIds.filter( + (entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId), + ), ].slice(0, Math.max(1, Math.floor(config.maxLoaded))); const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId); deps.logInfo?.( diff --git a/src/main/runtime/character-dictionary-open.ts b/src/main/runtime/character-dictionary-open.ts new file mode 100644 index 00000000..6db72628 --- /dev/null +++ b/src/main/runtime/character-dictionary-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary'; +const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500; + +export async function openCharacterDictionaryModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CHARACTER_DICTIONARY_MODAL, + timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS, + retryWarning: + 'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.characterDictionaryOpen, + modal: CHARACTER_DICTIONARY_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index d6bfe9c7..3be3aeeb 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -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'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 0a161295..37d4fd0d 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -36,6 +37,8 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; @@ -67,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, @@ -86,6 +90,8 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + getCharacterDictionarySelection: deps.getCharacterDictionarySelection, + setCharacterDictionarySelection: deps.setCharacterDictionarySelection, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index cb826a4c..763a80d6 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -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'), diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 6644283f..604c997a 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -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}`), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 18fb7106..8c51bd77 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetupWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; @@ -48,6 +49,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; @@ -92,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(), @@ -113,6 +117,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), generateCharacterDictionary: (targetPath?: string) => deps.generateCharacterDictionary(targetPath), + getCharacterDictionarySelection: deps.getCharacterDictionarySelection, + setCharacterDictionarySelection: deps.setCharacterDictionarySelection, runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 209a8b79..995d4271 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -25,6 +25,7 @@ function createDeps() { isOverlayInitialized: () => true, initializeOverlay: () => {}, toggleVisibleOverlay: () => {}, + togglePrimarySubtitleBar: () => {}, openFirstRunSetup: () => {}, setVisibleOverlay: () => {}, copyCurrentSubtitle: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index f4fd2f31..83750d3b 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = { isOverlayInitialized: () => boolean; initializeOverlay: () => void; toggleVisibleOverlay: () => void; + togglePrimarySubtitleBar: () => void; openFirstRunSetup: () => void; setVisibleOverlay: (visible: boolean) => void; copyCurrentSubtitle: () => void; @@ -41,6 +42,8 @@ export type CliCommandContextFactoryDeps = { getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + getCharacterDictionarySelection?: CliCommandRuntimeServiceContext['getCharacterDictionarySelection']; + setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; @@ -79,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, @@ -98,6 +102,23 @@ export function createCliCommandContext( getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + getCharacterDictionarySelection: + deps.getCharacterDictionarySelection ?? + (async () => ({ + seriesKey: '', + guessTitle: null, + current: null, + override: null, + candidates: [], + })), + setCharacterDictionarySelection: + deps.setCharacterDictionarySelection ?? + (async () => ({ + ok: false, + seriesKey: '', + selected: { id: 0, title: '', episodes: null }, + staleMediaIds: [], + })), runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 50c1cab4..57c2d1a8 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { showMpvOsd: () => {}, initializeOverlayRuntime: () => {}, toggleVisibleOverlay: () => {}, + togglePrimarySubtitleBar: () => {}, openFirstRunSetupWindow: () => {}, setVisibleOverlayVisible: () => {}, copyCurrentSubtitle: () => {}, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 80ca0557..0edc8b9a 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -20,12 +20,14 @@ function withTempDir(fn: (dir: string) => Promise | void): Promise | function makeArgs(overrides: Partial = {}): 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, @@ -51,6 +53,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, + openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, @@ -62,6 +65,9 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + dictionaryCandidates: false, + dictionarySelect: false, + dictionaryAnilistId: undefined, stats: false, jellyfin: false, jellyfinLogin: false, diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 737c801b..b3384c80 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { return Boolean( args.toggle || args.toggleVisibleOverlay || + args.togglePrimarySubtitleBar || args.launchMpv || args.settings || args.show || diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index e192fae7..d916540c 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index 995c0ce1..871bcc5f 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, + openCharacterDictionary: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/mpv-client-runtime-service-main-deps.ts b/src/main/runtime/mpv-client-runtime-service-main-deps.ts index c3c4fb20..b6169ae4 100644 --- a/src/main/runtime/mpv-client-runtime-service-main-deps.ts +++ b/src/main/runtime/mpv-client-runtime-service-main-deps.ts @@ -11,6 +11,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | null) => void; + shouldQuitOnMpvShutdown?: () => boolean; + requestAppQuit?: () => void; bindEventHandlers: (client: TClient) => void; }) { return () => ({ @@ -24,6 +26,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler< getReconnectTimer: () => deps.getReconnectTimer(), setReconnectTimer: (timer: ReturnType | null) => deps.setReconnectTimer(timer), + shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false, + requestAppQuit: () => deps.requestAppQuit?.(), }, bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client), }); diff --git a/src/main/runtime/mpv-client-runtime-service.ts b/src/main/runtime/mpv-client-runtime-service.ts index 325d665d..2fd0290b 100644 --- a/src/main/runtime/mpv-client-runtime-service.ts +++ b/src/main/runtime/mpv-client-runtime-service.ts @@ -7,6 +7,8 @@ export type MpvClientRuntimeServiceOptions = { isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | null) => void; + shouldQuitOnMpvShutdown?: () => boolean; + requestAppQuit?: () => void; }; type MpvClientLike = { diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts index 69c1126a..59c68cd1 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -16,6 +16,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call isOverlayShortcutContextActive: () => false, showMpvOsd: (text) => calls.push(`osd:${text}`), openRuntimeOptionsPalette: () => calls.push('runtime-options'), + openCharacterDictionary: () => calls.push('character-dictionary'), openJimaku: () => calls.push('jimaku'), markAudioCard: async () => { calls.push('mark-audio'); @@ -47,6 +48,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call assert.equal(shortcutsRegistered, true); deps.showMpvOsd('x'); deps.openRuntimeOptionsPalette(); + deps.openCharacterDictionary(); deps.openJimaku(); await deps.markAudioCard(); deps.copySubtitleMultiple(5000); @@ -63,6 +65,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call 'registered:true', 'osd:x', 'runtime-options', + 'character-dictionary', 'jimaku', 'mark-audio', 'copy-multi:5000', diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts index b6eb9b16..ef227b7d 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -11,6 +11,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler( isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true, showMpvOsd: (text: string) => deps.showMpvOsd(text), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openCharacterDictionary: () => deps.openCharacterDictionary(), openJimaku: () => deps.openJimaku(), markAudioCard: () => deps.markAudioCard(), copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index bffc375b..45bd1139 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -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', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index c38bf23e..4aa2e543 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -28,7 +28,7 @@ export function createResolveTrayIconPathHandler(deps: { export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { - openOverlay: () => void; + openSessionHelp: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; @@ -41,7 +41,7 @@ export function createBuildTrayMenuTemplateHandler(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(deps: { }) { return (): TMenuItem[] => { return deps.buildTrayMenuTemplateRuntime({ - openOverlay: () => { + openSessionHelp: () => { if (!deps.isOverlayRuntimeInitialized()) { deps.initializeOverlayRuntime(); } - deps.setVisibleOverlayVisible(true); + deps.openSessionHelpModal(); }, openFirstRunSetup: () => { deps.openFirstRunSetupWindow(); diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index d33ab8cc..6baea9f6 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -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'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 57e601b0..9ad9408b 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -27,7 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: { export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { - openOverlay: () => void; + openSessionHelp: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; @@ -40,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(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(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, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 16c1cc3b..407b6178 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -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); diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index f25ab59a..5b972264 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -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, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index f6b3ec89..083f3035 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -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 ? [ diff --git a/src/preload.ts b/src/preload.ts index b29be004..8972dda9 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -124,6 +124,9 @@ function createQueuedIpcListenerWithPayload( const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenCharacterDictionaryEvent = createQueuedIpcListener( + IPC_CHANNELS.event.characterDictionaryOpen, +); const onOpenControllerSelectEvent = createQueuedIpcListener( IPC_CHANNELS.event.controllerSelectOpen, ); @@ -150,6 +153,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.kikuFieldGroupingRequest, @@ -340,7 +346,9 @@ const electronAPI: ElectronAPI = { onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, + onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, + onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, @@ -363,6 +371,10 @@ const electronAPI: ElectronAPI = { request: YoutubePickerResolveRequest, ): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request), + getCharacterDictionarySelection: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection), + setCharacterDictionarySelection: (mediaId: number) => + ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId), notifyOverlayModalClosed: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 3d6caa0e..13addd2b 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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, @@ -365,6 +367,7 @@ function createKeyboardHandlerHarness() { const handlers = createKeyboardHandlers(ctx as never, { handleRuntimeOptionsKeydown: () => false, + handleCharacterDictionaryKeydown: () => false, handleSubsyncKeydown: () => false, handleKikuKeydown: () => false, handleJimakuKeydown: () => false, @@ -404,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(); @@ -1119,6 +1142,32 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async ( } }); +test('session binding: copy subtitle multiple captures follow-up digit locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.copySubtitleMultiple', + originalKey: 'Ctrl+M', + key: { code: 'KeyM', modifiers: ['ctrl'] }, + actionType: 'session-action', + actionId: 'copySubtitleMultiple', + }, + ] as never); + + testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM', ctrlKey: true }); + testGlobals.dispatchKeydown({ key: '3', code: 'Digit3' }); + + assert.deepEqual(testGlobals.sessionActions, [ + { actionId: 'copySubtitleMultiple', payload: { count: 3 } }, + ]); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: h moves left when popup is closed', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 33a617a4..4558a0d7 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -12,6 +12,7 @@ export function createKeyboardHandlers( ctx: RendererContext, options: { handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean; + handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean; @@ -360,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 { const marked = await window.electronAPI.markActiveVideoWatched(); if (marked) { @@ -1004,6 +1019,10 @@ export function createKeyboardHandlers( options.handleRuntimeOptionsKeydown(e); return; } + if (ctx.state.characterDictionaryModalOpen) { + options.handleCharacterDictionaryKeydown(e); + return; + } if (ctx.state.subsyncModalOpen) { options.handleSubsyncKeydown(e); return; @@ -1060,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(); @@ -1147,6 +1172,7 @@ export function createKeyboardHandlers( updateSessionBindings, syncKeyboardTokenSelection, handleSubtitleContentUpdated, + togglePrimarySubtitleBarVisibility, handleKeyboardModeToggleRequested, handleLookupWindowToggleRequested, closeLookupWindow, diff --git a/src/renderer/index.html b/src/renderer/index.html index 2221946a..fd06776a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -197,6 +197,20 @@ +