mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Improve startup dictionary progress and fix overlay/plugin input handlin
- show a dedicated startup OSD "building" phase for character dictionary sync - forward bare `Tab` from visible overlay to mpv so AniSkip works while focused - fix Windows plugin env override resolution for `SUBMINER_BINARY_PATH`
This commit is contained in:
@@ -29,7 +29,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
|
|||||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||||
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||||
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||||
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
|
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave
|
||||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||||
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
||||||
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
||||||
@@ -77,8 +77,6 @@ On first launch, SubMiner:
|
|||||||
- can install the mpv plugin to the default mpv scripts location for you
|
- can install the mpv plugin to the default mpv scripts location for you
|
||||||
- links directly to Yomitan settings so you can install dictionaries before finishing setup
|
- links directly to Yomitan settings so you can install dictionaries before finishing setup
|
||||||
|
|
||||||
Existing installs that already have a valid config plus at least one Yomitan dictionary are auto-detected as complete and will not be re-prompted.
|
|
||||||
|
|
||||||
### 3. Finish setup
|
### 3. Finish setup
|
||||||
|
|
||||||
- click `Install mpv plugin` if you want the default plugin auto-start flow
|
- click `Install mpv plugin` if you want the default plugin auto-start flow
|
||||||
@@ -114,7 +112,7 @@ For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.m
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Built on the shoulders of [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui), [mpvacious](https://github.com/Ajatt-Tools/mpvacious), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary). Subtitles powered by [Jimaku.cc](https://jimaku.cc). Dictionary lookups via [Yomitan](https://github.com/yomidevs/yomitan).
|
Built on the shoulders of [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary). Subtitles powered by [Jimaku.cc](https://jimaku.cc). Dictionary lookups via [Yomitan](https://github.com/yomidevs/yomitan), and JLPT tags from [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-145
|
||||||
|
title: Show character dictionary build progress on startup OSD before import
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 11:20'
|
||||||
|
updated_date: '2026-03-09 11:20'
|
||||||
|
labels:
|
||||||
|
- startup
|
||||||
|
- dictionary
|
||||||
|
- ux
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.test.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Surface an explicit character-dictionary build phase on startup OSD so there is visible progress between subtitle annotation loading and the later import/upload step when merged dictionary generation is still running.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Auto-sync emits a dedicated in-flight status while merged dictionary generation is running.
|
||||||
|
- [x] #2 Startup OSD sequencing treats that build phase as progress and can surface it after annotation loading clears.
|
||||||
|
- [x] #3 Regression coverage verifies the build phase is emitted before import begins.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Added a `building` progress phase before `buildMergedDictionary(...)` and included it in the startup OSD sequencer's buffered progress set. This gives startup a visible dictionary-progress step even when snapshot checking/generation finished too early to still be relevant by the time annotation loading completes.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: TASK-146
|
||||||
|
title: Forward overlay Tab to mpv for AniSkip
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-09 00:00'
|
||||||
|
updated_date: '2026-03-09 00:00'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- overlay
|
||||||
|
- aniskip
|
||||||
|
- linux
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Fix visible-overlay keyboard handling so bare `Tab` is forwarded to mpv instead of being consumed by Electron focus navigation. This restores the default AniSkip `TAB` binding while the overlay has focus, especially on Linux.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Visible overlay forwards bare `Tab` to mpv as `keypress TAB`.
|
||||||
|
- [x] #2 Modal overlays keep their existing local `Tab` behavior.
|
||||||
|
- [x] #3 Automated regression coverage exists for the input handler and overlay factory wiring.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Add a failing regression around visible-overlay `before-input-event` handling for bare `Tab`.
|
||||||
|
2. Add/extend overlay factory tests so the new mpv-forward callback is wired through runtime construction.
|
||||||
|
3. Patch overlay input handling to intercept visible-overlay `Tab` and send mpv `keypress TAB`.
|
||||||
|
4. Run focused overlay tests, typecheck, and changelog validation.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Extracted visible-overlay input handling into `src/core/services/overlay-window-input.ts` so the `Tab` forwarding decision can be unit tested without loading Electron window primitives.
|
||||||
|
|
||||||
|
Visible overlay `before-input-event` now intercepts bare `Tab`, prevents the browser default, and forwards mpv `keypress TAB` through the existing mpv runtime command path. Modal overlays remain unchanged.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun test src/core/services/overlay-window.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts`
|
||||||
|
- `bun x tsc --noEmit`
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Visible overlay focus no longer blocks the default AniSkip `Tab` binding. Bare `Tab` is now forwarded straight to mpv while the visible overlay is active, and modal overlays still retain their own normal focus behavior.
|
||||||
|
|
||||||
|
Added regression coverage for both the input-routing decision and the runtime plumbing that carries the new mpv forwarder into overlay window creation.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
id: TASK-148
|
||||||
|
title: Fix Windows plugin env binary override resolution
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-09 00:00'
|
||||||
|
updated_date: '2026-03-09 00:00'
|
||||||
|
labels:
|
||||||
|
- windows
|
||||||
|
- plugin
|
||||||
|
- regression
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Fix the mpv plugin's Windows binary override lookup so `SUBMINER_BINARY_PATH` still resolves when `SUBMINER_APPIMAGE_PATH` is unset. The current Lua resolver builds an array with a leading `nil`, which causes `ipairs` iteration to stop before the later Windows override candidate.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 `scripts/test-plugin-binary-windows.lua` passes the env override regression that expects `.exe` suffix resolution from `SUBMINER_BINARY_PATH`.
|
||||||
|
- [x] #2 Existing plugin start/binary test gate stays green after the fix.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Updated `plugin/subminer/binary.lua` so env override lookup checks `SUBMINER_APPIMAGE_PATH` and `SUBMINER_BINARY_PATH` sequentially instead of via a Lua array literal that truncates at the first `nil`. This restores Windows `.exe` suffix resolution for `SUBMINER_BINARY_PATH` when the AppImage env var is unset.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `lua scripts/test-plugin-binary-windows.lua`
|
||||||
|
- `bun run test:plugin:src`
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: changed
|
type: changed
|
||||||
area: overlay
|
area: dictionary
|
||||||
|
|
||||||
- Show `Checking character dictionary...` during startup auto-sync and only show `Generating character dictionary...` when a fresh character-dictionary snapshot rebuild is actually needed.
|
- Added a visible startup OSD step for merged character-dictionary building so long rebuilds show progress before the later import/upload phase.
|
||||||
|
|||||||
4
changes/task-146.md
Normal file
4
changes/task-146.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed visible overlay keyboard handling so pressing `Tab` still reaches mpv and triggers the default AniSkip skip-intro binding while the overlay has focus.
|
||||||
4
changes/task-148.md
Normal file
4
changes/task-148.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: plugin
|
||||||
|
|
||||||
|
- Fix Windows mpv plugin binary override lookup so `SUBMINER_BINARY_PATH` still resolves to `SubMiner.exe` when no AppImage override is set.
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
* SubMiner Example Configuration File
|
* SubMiner Example Configuration File
|
||||||
*
|
*
|
||||||
* This file is auto-generated from src/config/definitions.ts.
|
* This file is auto-generated from src/config/definitions.ts.
|
||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
* Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||||
"openBrowser": true, // Open browser setting. Values: true | false
|
"openBrowser": true // Open browser setting. Values: true | false
|
||||||
}, // Configure texthooker startup launch and browser opening behavior.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"websocket": {
|
"websocket": {
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
||||||
"port": 6677, // Built-in subtitle websocket server port.
|
"port": 6677 // Built-in subtitle websocket server port.
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"annotationWebsocket": {
|
"annotationWebsocket": {
|
||||||
"enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false
|
"enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false
|
||||||
"port": 6678, // Annotated subtitle websocket server port.
|
"port": 6678 // Annotated subtitle websocket server port.
|
||||||
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
|
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
||||||
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
||||||
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
||||||
"jellyfinRemoteSession": true, // Warm up Jellyfin remote session at startup. Values: true | false
|
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
|
||||||
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||||
"defaultMode": "hover", // Default mode setting.
|
"defaultMode": "hover" // Default mode setting.
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
"alass_path": "", // Alass path setting.
|
"alass_path": "", // Alass path setting.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||||
"replace": true, // Replace the active subtitle file when sync completes. Values: true | false
|
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10, // Y percent setting.
|
"yPercent": 10 // Y percent setting.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -158,7 +159,7 @@
|
|||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
"N3": "#f9e2af", // N3 setting.
|
"N3": "#f9e2af", // N3 setting.
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
"N4": "#a6e3a1", // N4 setting.
|
||||||
"N5": "#8aadf4", // N5 setting.
|
"N5": "#8aadf4" // N5 setting.
|
||||||
}, // Jlpt colors setting.
|
}, // Jlpt colors setting.
|
||||||
"frequencyDictionary": {
|
"frequencyDictionary": {
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
||||||
@@ -167,7 +168,13 @@
|
|||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
||||||
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
|
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
||||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
"bandedColors": [
|
||||||
|
"#ed8796",
|
||||||
|
"#f5a97f",
|
||||||
|
"#f9e2af",
|
||||||
|
"#8bd5ca",
|
||||||
|
"#8aadf4"
|
||||||
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||||
@@ -182,8 +189,8 @@
|
|||||||
"backgroundColor": "rgba(20, 22, 34, 0.78)", // Background color setting.
|
"backgroundColor": "rgba(20, 22, 34, 0.78)", // Background color setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"fontWeight": "600", // Font weight setting.
|
"fontWeight": "600", // Font weight setting.
|
||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal" // Font style setting.
|
||||||
}, // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -194,8 +201,10 @@
|
|||||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||||
|
"model": "openai/gpt-4o-mini", // Model setting.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||||
"requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||||
|
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -213,20 +222,22 @@
|
|||||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
||||||
"port": 8766, // Bind port for local AnkiConnect proxy.
|
"port": 8766, // Bind port for local AnkiConnect proxy.
|
||||||
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
||||||
}, // Proxy setting.
|
}, // Proxy setting.
|
||||||
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
"tags": [
|
||||||
|
"SubMiner"
|
||||||
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Audio setting.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Image setting.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Sentence setting.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Misc info setting.
|
||||||
"translation": "SelectionText", // Translation setting.
|
"translation": "SelectionText" // Translation setting.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||||
"model": "", // Optional model override for Anki AI translation/enrichment flows.
|
"model": "", // Optional model override for Anki AI translation/enrichment flows.
|
||||||
"systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows.
|
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||||
@@ -239,7 +250,7 @@
|
|||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated crf setting.
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Audio padding setting.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback duration setting.
|
||||||
"maxMediaDuration": 30, // Max media duration setting.
|
"maxMediaDuration": 30 // Max media duration setting.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||||
@@ -247,7 +258,7 @@
|
|||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Media insert mode setting.
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification type setting.
|
||||||
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
@@ -256,20 +267,20 @@
|
|||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
||||||
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enabled setting. Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||||
}, // Is kiku setting.
|
} // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -279,7 +290,7 @@
|
|||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -294,9 +305,12 @@
|
|||||||
"fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false
|
"fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false
|
||||||
"ai": {
|
"ai": {
|
||||||
"model": "", // Optional model override for YouTube subtitle AI post-processing.
|
"model": "", // Optional model override for YouTube subtitle AI post-processing.
|
||||||
"systemPrompt": "", // Optional system prompt override for YouTube subtitle AI post-processing.
|
"systemPrompt": "" // Optional system prompt override for YouTube subtitle AI post-processing.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
"primarySubLanguages": [
|
||||||
|
"ja",
|
||||||
|
"jpn"
|
||||||
|
] // Comma-separated primary subtitle language priority used by the launcher.
|
||||||
}, // Defaults for SubMiner YouTube subtitle generation.
|
}, // Defaults for SubMiner YouTube subtitle generation.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -317,9 +331,9 @@
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
||||||
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
|
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
|
||||||
"voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
|
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
|
||||||
}, // Collapsible sections setting.
|
} // Collapsible sections setting.
|
||||||
}, // Character dictionary setting.
|
} // Character dictionary setting.
|
||||||
}, // Anilist API credentials and update behavior.
|
}, // Anilist API credentials and update behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -343,8 +357,16 @@
|
|||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
"directPlayContainers": [
|
||||||
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
"mkv",
|
||||||
|
"mp4",
|
||||||
|
"webm",
|
||||||
|
"mov",
|
||||||
|
"flac",
|
||||||
|
"mp3",
|
||||||
|
"aac"
|
||||||
|
], // Container allowlist for direct play decisions.
|
||||||
|
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -355,7 +377,7 @@
|
|||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -377,7 +399,7 @@
|
|||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
"telemetryDays": 30, // Telemetry retention window in days.
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||||
}, // Retention setting.
|
} // Retention setting.
|
||||||
}, // Enable/disable immersion tracking.
|
} // Enable/disable immersion tracking.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,12 +107,8 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function find_binary_override()
|
local function find_binary_override()
|
||||||
local candidates = {
|
for _, env_name in ipairs({ "SUBMINER_APPIMAGE_PATH", "SUBMINER_BINARY_PATH" }) do
|
||||||
resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
|
local path = resolve_binary_candidate(os.getenv(env_name))
|
||||||
resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path in ipairs(candidates) do
|
|
||||||
if path and path ~= "" then
|
if path and path ~= "" then
|
||||||
return path
|
return path
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ local function run_plugin_scenario(config)
|
|||||||
return ""
|
return ""
|
||||||
end
|
end
|
||||||
|
|
||||||
function mp.get_property_native(_name)
|
function mp.get_property_native(name)
|
||||||
|
if name == "osd-dimensions" then
|
||||||
|
return config.osd_dimensions or {
|
||||||
|
w = 1280,
|
||||||
|
h = config.osd_height or 720,
|
||||||
|
}
|
||||||
|
end
|
||||||
return config.chapter_list or {}
|
return config.chapter_list or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,6 +53,12 @@ local function run_plugin_scenario(config)
|
|||||||
if name == "time-pos" then
|
if name == "time-pos" then
|
||||||
return config.time_pos
|
return config.time_pos
|
||||||
end
|
end
|
||||||
|
if name == "sub-pos" then
|
||||||
|
return config.sub_pos or 100
|
||||||
|
end
|
||||||
|
if name == "osd-height" then
|
||||||
|
return config.osd_height or 720
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -197,6 +209,12 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function utils.parse_json(json)
|
function utils.parse_json(json)
|
||||||
|
if json == '{"enabled":true,"amount":125}' then
|
||||||
|
return {
|
||||||
|
enabled = true,
|
||||||
|
amount = 125,
|
||||||
|
}, nil
|
||||||
|
end
|
||||||
if json == "__MAL_FOUND__" then
|
if json == "__MAL_FOUND__" then
|
||||||
return {
|
return {
|
||||||
categories = {
|
categories = {
|
||||||
@@ -641,7 +659,7 @@ do
|
|||||||
not has_property_set(recorded.property_sets, "pause", true),
|
not has_property_set(recorded.property_sets, "pause", true),
|
||||||
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export {
|
|||||||
syncOverlayWindowLayer,
|
syncOverlayWindowLayer,
|
||||||
updateOverlayWindowBounds,
|
updateOverlayWindowBounds,
|
||||||
} from './overlay-window';
|
} from './overlay-window';
|
||||||
|
export {
|
||||||
|
handleOverlayWindowBeforeInputEvent,
|
||||||
|
isTabInputForMpvForwarding,
|
||||||
|
} from './overlay-window-input';
|
||||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||||
export {
|
export {
|
||||||
|
|||||||
61
src/core/services/overlay-window-input.ts
Normal file
61
src/core/services/overlay-window-input.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export type OverlayWindowKind = 'visible' | 'modal';
|
||||||
|
|
||||||
|
export function isTabInputForMpvForwarding(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown' || input.isAutoRepeat) return false;
|
||||||
|
if (input.alt || input.control || input.meta || input.shift) return false;
|
||||||
|
return input.code === 'Tab' || input.key === 'Tab';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
||||||
|
if (input.type !== 'keyDown') return false;
|
||||||
|
if (input.alt) return false;
|
||||||
|
if (!input.control && !input.meta) return false;
|
||||||
|
if (!input.shift) return false;
|
||||||
|
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||||
|
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleOverlayWindowBeforeInputEvent(options: {
|
||||||
|
kind: OverlayWindowKind;
|
||||||
|
windowVisible: boolean;
|
||||||
|
input: Electron.Input;
|
||||||
|
preventDefault: () => void;
|
||||||
|
sendKeyboardModeToggleRequested: () => void;
|
||||||
|
sendLookupWindowToggleRequested: () => void;
|
||||||
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
|
}): boolean {
|
||||||
|
if (options.kind === 'modal') return false;
|
||||||
|
if (!options.windowVisible) return false;
|
||||||
|
|
||||||
|
if (isKeyboardModeToggleInput(options.input)) {
|
||||||
|
options.preventDefault();
|
||||||
|
options.sendKeyboardModeToggleRequested();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLookupWindowToggleInput(options.input)) {
|
||||||
|
options.preventDefault();
|
||||||
|
options.sendLookupWindowToggleRequested();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTabInputForMpvForwarding(options.input)) {
|
||||||
|
options.preventDefault();
|
||||||
|
options.forwardTabToMpv();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.tryHandleOverlayShortcutLocalFallback(options.input)) return false;
|
||||||
|
options.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
84
src/core/services/overlay-window.test.ts
Normal file
84
src/core/services/overlay-window.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
handleOverlayWindowBeforeInputEvent,
|
||||||
|
isTabInputForMpvForwarding,
|
||||||
|
} from './overlay-window-input';
|
||||||
|
|
||||||
|
test('isTabInputForMpvForwarding matches bare Tab keydown only', () => {
|
||||||
|
assert.equal(
|
||||||
|
isTabInputForMpvForwarding({
|
||||||
|
type: 'keyDown',
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
} as Electron.Input),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isTabInputForMpvForwarding({
|
||||||
|
type: 'keyDown',
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
shift: true,
|
||||||
|
} as Electron.Input),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isTabInputForMpvForwarding({
|
||||||
|
type: 'keyUp',
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
} as Electron.Input),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleOverlayWindowBeforeInputEvent forwards Tab to mpv for visible overlays', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = handleOverlayWindowBeforeInputEvent({
|
||||||
|
kind: 'visible',
|
||||||
|
windowVisible: true,
|
||||||
|
input: {
|
||||||
|
type: 'keyDown',
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
} as Electron.Input,
|
||||||
|
preventDefault: () => calls.push('prevent-default'),
|
||||||
|
sendKeyboardModeToggleRequested: () => calls.push('keyboard-mode'),
|
||||||
|
sendLookupWindowToggleRequested: () => calls.push('lookup-toggle'),
|
||||||
|
tryHandleOverlayShortcutLocalFallback: () => {
|
||||||
|
calls.push('fallback');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['prevent-default', 'forward-tab']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = handleOverlayWindowBeforeInputEvent({
|
||||||
|
kind: 'modal',
|
||||||
|
windowVisible: true,
|
||||||
|
input: {
|
||||||
|
type: 'keyDown',
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
} as Electron.Input,
|
||||||
|
preventDefault: () => calls.push('prevent-default'),
|
||||||
|
sendKeyboardModeToggleRequested: () => calls.push('keyboard-mode'),
|
||||||
|
sendLookupWindowToggleRequested: () => calls.push('lookup-toggle'),
|
||||||
|
tryHandleOverlayShortcutLocalFallback: () => {
|
||||||
|
calls.push('fallback');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@ import * as path from 'path';
|
|||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import {
|
||||||
|
handleOverlayWindowBeforeInputEvent,
|
||||||
|
type OverlayWindowKind,
|
||||||
|
} from './overlay-window-input';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||||
@@ -23,26 +27,6 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OverlayWindowKind = 'visible' | 'modal';
|
|
||||||
|
|
||||||
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
|
||||||
if (input.type !== 'keyDown') return false;
|
|
||||||
if (input.alt) return false;
|
|
||||||
if (!input.control && !input.meta) return false;
|
|
||||||
if (input.shift) return false;
|
|
||||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
|
||||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
|
||||||
if (input.type !== 'keyDown') return false;
|
|
||||||
if (input.alt) return false;
|
|
||||||
if (!input.control && !input.meta) return false;
|
|
||||||
if (!input.shift) return false;
|
|
||||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
|
||||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateOverlayWindowBounds(
|
export function updateOverlayWindowBounds(
|
||||||
geometry: WindowGeometry,
|
geometry: WindowGeometry,
|
||||||
window: BrowserWindow | null,
|
window: BrowserWindow | null,
|
||||||
@@ -92,6 +76,7 @@ export function createOverlayWindow(
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||||
},
|
},
|
||||||
): BrowserWindow {
|
): BrowserWindow {
|
||||||
@@ -142,20 +127,19 @@ export function createOverlayWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.on('before-input-event', (event, input) => {
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
if (kind === 'modal') return;
|
handleOverlayWindowBeforeInputEvent({
|
||||||
if (!window.isVisible()) return;
|
kind,
|
||||||
if (isKeyboardModeToggleInput(input)) {
|
windowVisible: window.isVisible(),
|
||||||
event.preventDefault();
|
input,
|
||||||
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
|
preventDefault: () => event.preventDefault(),
|
||||||
return;
|
sendKeyboardModeToggleRequested: () =>
|
||||||
}
|
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested),
|
||||||
if (isLookupWindowToggleInput(input)) {
|
sendLookupWindowToggleRequested: () =>
|
||||||
event.preventDefault();
|
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested),
|
||||||
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
|
tryHandleOverlayShortcutLocalFallback: (nextInput) =>
|
||||||
return;
|
options.tryHandleOverlayShortcutLocalFallback(nextInput),
|
||||||
}
|
forwardTabToMpv: () => options.forwardTabToMpv(),
|
||||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
});
|
||||||
event.preventDefault();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.hide();
|
window.hide();
|
||||||
@@ -185,3 +169,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
|
|||||||
if (overlayWindowLayerByInstance.get(window) === layer) return;
|
if (overlayWindowLayerByInstance.get(window) === layer) return;
|
||||||
loadOverlayWindowLayer(window, layer);
|
loadOverlayWindowLayer(window, layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { OverlayWindowKind } from './overlay-window-input';
|
||||||
|
|||||||
@@ -3514,6 +3514,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
overlayManager.setMainWindow(null);
|
overlayManager.setMainWindow(null);
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ test('auto sync invokes completion callback after successful sync', async () =>
|
|||||||
test('auto sync emits progress events for start import and completion', async () => {
|
test('auto sync emits progress events for start import and completion', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const events: Array<{
|
const events: Array<{
|
||||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
||||||
mediaId?: number;
|
mediaId?: number;
|
||||||
mediaTitle?: string;
|
mediaTitle?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -406,6 +406,12 @@ test('auto sync emits progress events for start import and completion', async ()
|
|||||||
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
phase: 'building',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Building character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
phase: 'importing',
|
phase: 'importing',
|
||||||
mediaId: 101291,
|
mediaId: 101291,
|
||||||
@@ -425,7 +431,7 @@ test('auto sync emits progress events for start import and completion', async ()
|
|||||||
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
|
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const events: Array<{
|
const events: Array<{
|
||||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
||||||
mediaId?: number;
|
mediaId?: number;
|
||||||
mediaTitle?: string;
|
mediaTitle?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -503,6 +509,77 @@ test('auto sync emits checking before snapshot resolves and skips generating on
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auto sync emits building while merged dictionary generation is in flight', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const events: Array<{
|
||||||
|
phase: 'checking' | 'generating' | 'building' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||||
|
mediaId?: number;
|
||||||
|
mediaTitle?: string;
|
||||||
|
message: string;
|
||||||
|
changed?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const buildDeferred = createDeferred<{
|
||||||
|
zipPath: string;
|
||||||
|
revision: string;
|
||||||
|
dictionaryTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
}>();
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||||
|
progress?.onChecking?.({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
entryCount: 2560,
|
||||||
|
fromCache: true,
|
||||||
|
updatedAt: 1000,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
buildMergedDictionary: async () => await buildDeferred.promise,
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
importedRevision = 'rev-101291';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
onSyncStatus: (event) => {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncPromise = runtime.runSyncNow();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
events.some((event) => event.phase === 'building'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
buildDeferred.resolve({
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-101291',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 2560,
|
||||||
|
});
|
||||||
|
await syncPromise;
|
||||||
|
});
|
||||||
|
|
||||||
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
|
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const gate = (() => {
|
const gate = (() => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface CharacterDictionaryAutoSyncConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CharacterDictionaryAutoSyncStatusEvent {
|
export interface CharacterDictionaryAutoSyncStatusEvent {
|
||||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
||||||
mediaId?: number;
|
mediaId?: number;
|
||||||
mediaTitle?: string;
|
mediaTitle?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -123,6 +123,10 @@ function buildImportingMessage(mediaTitle: string): string {
|
|||||||
return `Importing character dictionary for ${mediaTitle}...`;
|
return `Importing character dictionary for ${mediaTitle}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBuildingMessage(mediaTitle: string): string {
|
||||||
|
return `Building character dictionary for ${mediaTitle}...`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildReadyMessage(mediaTitle: string): string {
|
function buildReadyMessage(mediaTitle: string): string {
|
||||||
return `Character dictionary ready for ${mediaTitle}`;
|
return `Character dictionary ready for ${mediaTitle}`;
|
||||||
}
|
}
|
||||||
@@ -227,6 +231,12 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
!state.mergedDictionaryTitle ||
|
!state.mergedDictionaryTitle ||
|
||||||
!snapshot.fromCache
|
!snapshot.fromCache
|
||||||
) {
|
) {
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'building',
|
||||||
|
mediaId: snapshot.mediaId,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
message: buildBuildingMessage(snapshot.mediaTitle),
|
||||||
|
});
|
||||||
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
|
||||||
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
|||||||
|
|
||||||
applyHotReload(
|
applyHotReload(
|
||||||
{
|
{
|
||||||
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
|
hotReloadFields: [
|
||||||
|
'shortcuts',
|
||||||
|
'secondarySub.defaultMode',
|
||||||
|
'ankiConnect.ai',
|
||||||
|
'subtitleStyle.autoPauseVideoOnHover',
|
||||||
|
],
|
||||||
restartRequiredFields: [],
|
restartRequiredFields: [],
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
|
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
|
||||||
isOverlayVisible: (kind) => kind === 'visible',
|
isOverlayVisible: (kind) => kind === 'visible',
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlayDeps = buildOverlayDeps();
|
const overlayDeps = buildOverlayDeps();
|
||||||
assert.equal(overlayDeps.isDev, true);
|
assert.equal(overlayDeps.isDev, true);
|
||||||
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
|
||||||
|
overlayDeps.forwardTabToMpv();
|
||||||
|
|
||||||
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
|
||||||
createOverlayWindow: () => ({ id: 'visible' }),
|
createOverlayWindow: () => ({ id: 'visible' }),
|
||||||
@@ -37,5 +39,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
|||||||
const modalDeps = buildModalDeps();
|
const modalDeps = buildModalDeps();
|
||||||
modalDeps.setModalWindow(null);
|
modalDeps.setModalWindow(null);
|
||||||
|
|
||||||
assert.deepEqual(calls, ['set-main', 'set-modal']);
|
assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
},
|
},
|
||||||
) => TWindow;
|
) => TWindow;
|
||||||
@@ -17,6 +18,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -27,6 +29,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
|
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
|
||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('create overlay window handler forwards options and kind', () => {
|
|||||||
assert.equal(options.isDev, true);
|
assert.equal(options.isDev, true);
|
||||||
assert.equal(options.isOverlayVisible('visible'), true);
|
assert.equal(options.isOverlayVisible('visible'), true);
|
||||||
assert.equal(options.isOverlayVisible('modal'), false);
|
assert.equal(options.isOverlayVisible('modal'), false);
|
||||||
|
options.forwardTabToMpv();
|
||||||
options.onRuntimeOptionsChanged();
|
options.onRuntimeOptionsChanged();
|
||||||
options.setOverlayDebugVisualizationEnabled(true);
|
options.setOverlayDebugVisualizationEnabled(true);
|
||||||
options.onWindowClosed(kind);
|
options.onWindowClosed(kind);
|
||||||
@@ -26,11 +27,18 @@ test('create overlay window handler forwards options and kind', () => {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
|
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
|
||||||
isOverlayVisible: (kind) => kind === 'visible',
|
isOverlayVisible: (kind) => kind === 'visible',
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(createOverlayWindow('visible'), window);
|
assert.equal(createOverlayWindow('visible'), window);
|
||||||
assert.deepEqual(calls, ['kind:visible', 'runtime-options', 'debug:true', 'closed:visible']);
|
assert.deepEqual(calls, [
|
||||||
|
'kind:visible',
|
||||||
|
'forward-tab',
|
||||||
|
'runtime-options',
|
||||||
|
'debug:true',
|
||||||
|
'closed:visible',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create main window handler stores visible window', () => {
|
test('create main window handler stores visible window', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
},
|
},
|
||||||
) => TWindow;
|
) => TWindow;
|
||||||
@@ -19,6 +20,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
|
forwardTabToMpv: () => void;
|
||||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||||
}) {
|
}) {
|
||||||
return (kind: OverlayWindowKind): TWindow => {
|
return (kind: OverlayWindowKind): TWindow => {
|
||||||
@@ -29,6 +31,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
|||||||
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
|
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
|
||||||
isOverlayVisible: deps.isOverlayVisible,
|
isOverlayVisible: deps.isOverlayVisible,
|
||||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||||
|
forwardTabToMpv: deps.forwardTabToMpv,
|
||||||
onWindowClosed: deps.onWindowClosed,
|
onWindowClosed: deps.onWindowClosed,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
|
|||||||
},
|
},
|
||||||
isOverlayVisible: (kind) => kind === 'visible',
|
isOverlayVisible: (kind) => kind === 'visible',
|
||||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||||
|
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||||
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
|
||||||
},
|
},
|
||||||
setMainWindow: (window) => {
|
setMainWindow: (window) => {
|
||||||
|
|||||||
@@ -72,6 +72,31 @@ test('startup OSD buffers checking behind annotations and replaces it with later
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startup OSD replaces earlier dictionary progress with later building progress', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('building', 'Building character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Building character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
|
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
const sequencer = createStartupOsdSequencer({
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
||||||
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +74,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
event.phase === 'checking' ||
|
event.phase === 'checking' ||
|
||||||
event.phase === 'generating' ||
|
event.phase === 'generating' ||
|
||||||
event.phase === 'syncing' ||
|
event.phase === 'syncing' ||
|
||||||
|
event.phase === 'building' ||
|
||||||
event.phase === 'importing'
|
event.phase === 'importing'
|
||||||
) {
|
) {
|
||||||
pendingDictionaryProgress = event;
|
pendingDictionaryProgress = event;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function createContext(subtitleHeight: number) {
|
|||||||
state: {
|
state: {
|
||||||
currentYPercent: null,
|
currentYPercent: null,
|
||||||
persistedSubtitlePosition: { yPercent: 10 },
|
persistedSubtitlePosition: { yPercent: 10 },
|
||||||
|
isOverSubtitle: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ function getNextPersistedPosition(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMarginBottom(ctx: RendererContext, yPercent: number): void {
|
||||||
|
const clampedPercent = clampYPercent(ctx, yPercent);
|
||||||
|
ctx.state.currentYPercent = clampedPercent;
|
||||||
|
const marginBottom = (clampedPercent / 100) * getViewportHeight();
|
||||||
|
|
||||||
|
ctx.dom.subtitleContainer.style.position = '';
|
||||||
|
ctx.dom.subtitleContainer.style.left = '';
|
||||||
|
ctx.dom.subtitleContainer.style.top = '';
|
||||||
|
ctx.dom.subtitleContainer.style.right = '';
|
||||||
|
ctx.dom.subtitleContainer.style.transform = '';
|
||||||
|
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createInMemorySubtitlePositionController(
|
export function createInMemorySubtitlePositionController(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
): SubtitlePositionController {
|
): SubtitlePositionController {
|
||||||
@@ -98,16 +111,7 @@ export function createInMemorySubtitlePositionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyYPercent(yPercent: number): void {
|
function applyYPercent(yPercent: number): void {
|
||||||
const clampedPercent = clampYPercent(ctx, yPercent);
|
applyMarginBottom(ctx, yPercent);
|
||||||
ctx.state.currentYPercent = clampedPercent;
|
|
||||||
const marginBottom = (clampedPercent / 100) * getViewportHeight();
|
|
||||||
|
|
||||||
ctx.dom.subtitleContainer.style.position = '';
|
|
||||||
ctx.dom.subtitleContainer.style.left = '';
|
|
||||||
ctx.dom.subtitleContainer.style.top = '';
|
|
||||||
ctx.dom.subtitleContainer.style.right = '';
|
|
||||||
ctx.dom.subtitleContainer.style.transform = '';
|
|
||||||
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
||||||
|
|||||||
@@ -374,7 +374,8 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
await keyboardHandlers.setupMpvInputForwarding();
|
||||||
|
|
||||||
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
|
||||||
positioning.applyStoredSubtitlePosition(
|
positioning.applyStoredSubtitlePosition(
|
||||||
await window.electronAPI.getSubtitlePosition(),
|
await window.electronAPI.getSubtitlePosition(),
|
||||||
|
|||||||
Reference in New Issue
Block a user