diff --git a/README.md b/README.md index acc28c4..014b688 100644 --- a/README.md +++ b/README.md @@ -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 - **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 -- **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 - **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 @@ -77,8 +77,6 @@ On first launch, SubMiner: - 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 -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 - 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 -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 diff --git a/backlog/tasks/task-145 - Show-character-dictionary-build-progress-on-startup-OSD-before-import.md b/backlog/tasks/task-145 - Show-character-dictionary-build-progress-on-startup-OSD-before-import.md new file mode 100644 index 0000000..0d5f897 --- /dev/null +++ b/backlog/tasks/task-145 - Show-character-dictionary-build-progress-on-startup-OSD-before-import.md @@ -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 + + + +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. + + + +## Acceptance Criteria + + + +- [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. + + +## Implementation Notes + + + +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. + + diff --git a/backlog/tasks/task-146 - Forward-overlay-Tab-to-mpv-for-AniSkip.md b/backlog/tasks/task-146 - Forward-overlay-Tab-to-mpv-for-AniSkip.md new file mode 100644 index 0000000..c8b9e83 --- /dev/null +++ b/backlog/tasks/task-146 - Forward-overlay-Tab-to-mpv-for-AniSkip.md @@ -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 + + + +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. + + + +## Acceptance Criteria + + + +- [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. + + +## Implementation Plan + + + +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. + + +## Implementation Notes + + + +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` + + +## Final Summary + + + +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. + + diff --git a/backlog/tasks/task-148 - Fix-Windows-plugin-env-binary-override-resolution.md b/backlog/tasks/task-148 - Fix-Windows-plugin-env-binary-override-resolution.md new file mode 100644 index 0000000..3092ff9 --- /dev/null +++ b/backlog/tasks/task-148 - Fix-Windows-plugin-env-binary-override-resolution.md @@ -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 + + + +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. + + + +## Acceptance Criteria + + + +- [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. + + + +## Final Summary + + + +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` + + diff --git a/changes/task-145.md b/changes/task-145.md index 3ebf2e4..9747344 100644 --- a/changes/task-145.md +++ b/changes/task-145.md @@ -1,4 +1,4 @@ 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. diff --git a/changes/task-146.md b/changes/task-146.md new file mode 100644 index 0000000..e7a77b7 --- /dev/null +++ b/changes/task-146.md @@ -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. diff --git a/changes/task-148.md b/changes/task-148.md new file mode 100644 index 0000000..db6bfb6 --- /dev/null +++ b/changes/task-148.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index d831e9c..29b79f4 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -2,9 +2,10 @@ * SubMiner Example Configuration File * * 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 // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. @@ -17,7 +18,7 @@ // ========================================== "texthooker": { "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. // ========================================== @@ -27,7 +28,7 @@ // ========================================== "websocket": { "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. // ========================================== @@ -37,7 +38,7 @@ // ========================================== "annotationWebsocket": { "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. // ========================================== @@ -46,7 +47,7 @@ // Set to debug for full runtime diagnostics. // ========================================== "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. // ========================================== @@ -60,7 +61,7 @@ "mecab": true, // Warm up MeCab tokenizer 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 - "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. // ========================================== @@ -81,7 +82,7 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card 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. // ========================================== @@ -101,7 +102,7 @@ "secondarySub": { "secondarySubLanguages": [], // Secondary sub languages setting. "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false - "defaultMode": "hover", // Default mode setting. + "defaultMode": "hover" // Default mode setting. }, // Dual subtitle track options. // ========================================== @@ -113,7 +114,7 @@ "alass_path": "", // Alass path setting. "ffsubsync_path": "", // Ffsubsync 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. // ========================================== @@ -121,7 +122,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10, // Y percent setting. + "yPercent": 10 // Y percent setting. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -158,7 +159,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#a6e3a1", // N4 setting. - "N5": "#8aadf4", // N5 setting. + "N5": "#8aadf4" // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "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 "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`. - "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. "secondary": { "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. "backdropFilter": "blur(6px)", // Backdrop filter setting. "fontWeight": "600", // Font weight setting. - "fontStyle": "normal", // Font style setting. - }, // Secondary setting. + "fontStyle": "normal" // Font style setting. + } // Secondary setting. }, // Primary and secondary subtitle styling. // ========================================== @@ -194,8 +201,10 @@ "enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false "apiKey": "", // Static API key for the shared OpenAI-compatible AI provider. "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. - "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. // ========================================== @@ -213,20 +222,22 @@ "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. "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. - "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": { "audio": "ExpressionAudio", // Audio setting. "image": "Picture", // Image setting. "sentence": "Sentence", // Sentence setting. "miscInfo": "MiscInfo", // Misc info setting. - "translation": "SelectionText", // Translation setting. + "translation": "SelectionText" // Translation setting. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "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. "media": { "generateAudio": true, // Generate audio setting. Values: true | false @@ -239,7 +250,7 @@ "animatedCrf": 35, // Animated crf setting. "audioPadding": 0.5, // Audio padding setting. "fallbackDuration": 3, // Fallback duration setting. - "maxMediaDuration": 30, // Max media duration setting. + "maxMediaDuration": 30 // Max media duration setting. }, // Media setting. "behavior": { "overwriteAudio": true, // Overwrite audio setting. Values: true | false @@ -247,7 +258,7 @@ "mediaInsertMode": "append", // Media insert mode setting. "highlightWord": true, // Highlight word setting. Values: true | false "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. "nPlusOne": { "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. "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. - "knownWord": "#a6da95", // Color used for legacy known-word highlights. + "knownWord": "#a6da95" // Color used for legacy known-word highlights. }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)", // Pattern setting. + "pattern": "[SubMiner] %f (%t)" // Pattern setting. }, // Metadata setting. "isLapis": { "enabled": false, // Enabled setting. Values: true | false - "sentenceCardModel": "Japanese sentences", // Sentence card model setting. + "sentenceCardModel": "Japanese sentences" // Sentence card model setting. }, // Is lapis setting. "isKiku": { "enabled": false, // Enabled setting. Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false - }, // Is kiku setting. + "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + } // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -279,7 +290,7 @@ "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Api base url setting. "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. // ========================================== @@ -294,9 +305,12 @@ "fixWithAi": false, // Use shared AI provider to post-process whisper-generated YouTube subtitles. Values: true | false "ai": { "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. - "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. // ========================================== @@ -317,9 +331,9 @@ "collapsibleSections": { "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 - "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - }, // Collapsible sections setting. - }, // Character dictionary setting. + "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + } // Collapsible sections setting. + } // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -343,8 +357,16 @@ "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. "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. - "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": [ + "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. // ========================================== @@ -355,7 +377,7 @@ "discordPresence": { "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false "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. // ========================================== @@ -377,7 +399,7 @@ "telemetryDays": 30, // Telemetry retention window in days. "dailyRollupsDays": 365, // Daily rollup retention window in days. "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. - "vacuumIntervalDays": 7, // Minimum days between VACUUM runs. - }, // Retention setting. - }, // Enable/disable immersion tracking. + "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. + } // Retention setting. + } // Enable/disable immersion tracking. } diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua index 49bfba3..9a3519f 100644 --- a/plugin/subminer/binary.lua +++ b/plugin/subminer/binary.lua @@ -107,12 +107,8 @@ function M.create(ctx) end local function find_binary_override() - local candidates = { - resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")), - resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")), - } - - for _, path in ipairs(candidates) do + for _, env_name in ipairs({ "SUBMINER_APPIMAGE_PATH", "SUBMINER_BINARY_PATH" }) do + local path = resolve_binary_candidate(os.getenv(env_name)) if path and path ~= "" then return path end diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index eda213d..4471d25 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -39,7 +39,13 @@ local function run_plugin_scenario(config) return "" 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 {} end @@ -47,6 +53,12 @@ local function run_plugin_scenario(config) if name == "time-pos" then return config.time_pos 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 end @@ -197,6 +209,12 @@ local function run_plugin_scenario(config) end function utils.parse_json(json) + if json == '{"enabled":true,"amount":125}' then + return { + enabled = true, + amount = 125, + }, nil + end if json == "__MAL_FOUND__" then return { categories = { @@ -641,7 +659,7 @@ do not has_property_set(recorded.property_sets, "pause", true), "auto-start visible overlay should not force pause without explicit pause-until-ready option" ) - end +end do local recorded, err = run_plugin_scenario({ diff --git a/src/core/services/index.ts b/src/core/services/index.ts index c059eda..400cf70 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -72,6 +72,10 @@ export { syncOverlayWindowLayer, updateOverlayWindowBounds, } from './overlay-window'; +export { + handleOverlayWindowBeforeInputEvent, + isTabInputForMpvForwarding, +} from './overlay-window-input'; export { initializeOverlayRuntime } from './overlay-runtime-init'; export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; export { diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts new file mode 100644 index 0000000..33f31bb --- /dev/null +++ b/src/core/services/overlay-window-input.ts @@ -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; +} diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts new file mode 100644 index 0000000..1fa1cfa --- /dev/null +++ b/src/core/services/overlay-window.test.ts @@ -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, []); +}); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 1262789..b1dd61c 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -3,6 +3,10 @@ import * as path from 'path'; import { WindowGeometry } from '../../types'; import { createLogger } from '../../logger'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { + handleOverlayWindowBeforeInputEvent, + type OverlayWindowKind, +} from './overlay-window-input'; const logger = createLogger('main:overlay-window'); const overlayWindowLayerByInstance = new WeakMap(); @@ -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( geometry: WindowGeometry, window: BrowserWindow | null, @@ -92,6 +76,7 @@ export function createOverlayWindow( setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; isOverlayVisible: (kind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; onWindowClosed: (kind: OverlayWindowKind) => void; }, ): BrowserWindow { @@ -142,20 +127,19 @@ export function createOverlayWindow( } window.webContents.on('before-input-event', (event, input) => { - if (kind === 'modal') return; - if (!window.isVisible()) return; - if (isKeyboardModeToggleInput(input)) { - event.preventDefault(); - window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested); - return; - } - if (isLookupWindowToggleInput(input)) { - event.preventDefault(); - window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested); - return; - } - if (!options.tryHandleOverlayShortcutLocalFallback(input)) return; - event.preventDefault(); + handleOverlayWindowBeforeInputEvent({ + kind, + windowVisible: window.isVisible(), + input, + preventDefault: () => event.preventDefault(), + sendKeyboardModeToggleRequested: () => + window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested), + sendLookupWindowToggleRequested: () => + window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested), + tryHandleOverlayShortcutLocalFallback: (nextInput) => + options.tryHandleOverlayShortcutLocalFallback(nextInput), + forwardTabToMpv: () => options.forwardTabToMpv(), + }); }); window.hide(); @@ -185,3 +169,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'): if (overlayWindowLayerByInstance.get(window) === layer) return; loadOverlayWindowLayer(window, layer); } + +export type { OverlayWindowKind } from './overlay-window-input'; diff --git a/src/main.ts b/src/main.ts index 6c7c541..ce678fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3514,6 +3514,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), + forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { overlayManager.setMainWindow(null); diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index c406795..81cf892 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -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 () => { const userDataPath = makeTempDir(); const events: Array<{ - phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed'; + phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; mediaId?: number; mediaTitle?: 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', 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', 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 () => { const userDataPath = makeTempDir(); const events: Array<{ - phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed'; + phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; mediaId?: number; mediaTitle?: 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 () => { const userDataPath = makeTempDir(); const gate = (() => { diff --git a/src/main/runtime/character-dictionary-auto-sync.ts b/src/main/runtime/character-dictionary-auto-sync.ts index d5d21ca..e214bbe 100644 --- a/src/main/runtime/character-dictionary-auto-sync.ts +++ b/src/main/runtime/character-dictionary-auto-sync.ts @@ -25,7 +25,7 @@ export interface CharacterDictionaryAutoSyncConfig { } export interface CharacterDictionaryAutoSyncStatusEvent { - phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed'; + phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; mediaId?: number; mediaTitle?: string; message: string; @@ -123,6 +123,10 @@ function buildImportingMessage(mediaTitle: string): string { return `Importing character dictionary for ${mediaTitle}...`; } +function buildBuildingMessage(mediaTitle: string): string { + return `Building character dictionary for ${mediaTitle}...`; +} + function buildReadyMessage(mediaTitle: string): string { return `Character dictionary ready for ${mediaTitle}`; } @@ -227,6 +231,12 @@ export function createCharacterDictionaryAutoSyncRuntimeService( !state.mergedDictionaryTitle || !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'); merged = await deps.buildMergedDictionary(nextActiveMediaIds); } diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 1604d53..0a5e228 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -25,7 +25,12 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { applyHotReload( { - hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'], + hotReloadFields: [ + 'shortcuts', + 'secondarySub.defaultMode', + 'ankiConnect.ai', + 'subtitleStyle.autoPauseVideoOnHover', + ], restartRequiredFields: [], }, config, diff --git a/src/main/runtime/overlay-window-factory-main-deps.test.ts b/src/main/runtime/overlay-window-factory-main-deps.test.ts index ceaf216..11d73af 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.test.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.test.ts @@ -16,12 +16,14 @@ test('overlay window factory main deps builders return mapped handlers', () => { setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), isOverlayVisible: (kind) => kind === 'visible', tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), }); const overlayDeps = buildOverlayDeps(); assert.equal(overlayDeps.isDev, true); assert.equal(overlayDeps.isOverlayVisible('visible'), true); + overlayDeps.forwardTabToMpv(); const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({ createOverlayWindow: () => ({ id: 'visible' }), @@ -37,5 +39,5 @@ test('overlay window factory main deps builders return mapped handlers', () => { const modalDeps = buildModalDeps(); modalDeps.setModalWindow(null); - assert.deepEqual(calls, ['set-main', 'set-modal']); + assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']); }); diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index d2fb69d..8475ce7 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -8,6 +8,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; }, ) => TWindow; @@ -17,6 +18,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; }) { return () => ({ @@ -27,6 +29,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + forwardTabToMpv: deps.forwardTabToMpv, onWindowClosed: deps.onWindowClosed, }); } diff --git a/src/main/runtime/overlay-window-factory.test.ts b/src/main/runtime/overlay-window-factory.test.ts index 8d45f98..f06a29b 100644 --- a/src/main/runtime/overlay-window-factory.test.ts +++ b/src/main/runtime/overlay-window-factory.test.ts @@ -15,6 +15,7 @@ test('create overlay window handler forwards options and kind', () => { assert.equal(options.isDev, true); assert.equal(options.isOverlayVisible('visible'), true); assert.equal(options.isOverlayVisible('modal'), false); + options.forwardTabToMpv(); options.onRuntimeOptionsChanged(); options.setOverlayDebugVisualizationEnabled(true); options.onWindowClosed(kind); @@ -26,11 +27,18 @@ test('create overlay window handler forwards options and kind', () => { setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`), isOverlayVisible: (kind) => kind === 'visible', tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), }); 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', () => { diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index 3b6439f..9428264 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -10,6 +10,7 @@ export function createCreateOverlayWindowHandler(deps: { setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; }, ) => TWindow; @@ -19,6 +20,7 @@ export function createCreateOverlayWindowHandler(deps: { setOverlayDebugVisualizationEnabled: (enabled: boolean) => void; isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + forwardTabToMpv: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; }) { return (kind: OverlayWindowKind): TWindow => { @@ -29,6 +31,7 @@ export function createCreateOverlayWindowHandler(deps: { setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled, isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, + forwardTabToMpv: deps.forwardTabToMpv, onWindowClosed: deps.onWindowClosed, }); }; diff --git a/src/main/runtime/overlay-window-runtime-handlers.test.ts b/src/main/runtime/overlay-window-runtime-handlers.test.ts index f4c38f4..d7b43d6 100644 --- a/src/main/runtime/overlay-window-runtime-handlers.test.ts +++ b/src/main/runtime/overlay-window-runtime-handlers.test.ts @@ -19,6 +19,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () => }, isOverlayVisible: (kind) => kind === 'visible', tryHandleOverlayShortcutLocalFallback: () => false, + forwardTabToMpv: () => calls.push('forward-tab'), onWindowClosed: (kind) => calls.push(`closed:${kind}`), }, setMainWindow: (window) => { diff --git a/src/main/runtime/startup-osd-sequencer.test.ts b/src/main/runtime/startup-osd-sequencer.test.ts index 92d3afa..fc8e2fd 100644 --- a/src/main/runtime/startup-osd-sequencer.test.ts +++ b/src/main/runtime/startup-osd-sequencer.test.ts @@ -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', () => { const osdMessages: string[] = []; const sequencer = createStartupOsdSequencer({ diff --git a/src/main/runtime/startup-osd-sequencer.ts b/src/main/runtime/startup-osd-sequencer.ts index 348d915..cc66d84 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -1,5 +1,5 @@ export interface StartupOsdSequencerCharacterDictionaryEvent { - phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed'; + phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; message: string; } @@ -74,6 +74,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => event.phase === 'checking' || event.phase === 'generating' || event.phase === 'syncing' || + event.phase === 'building' || event.phase === 'importing' ) { pendingDictionaryProgress = event; diff --git a/src/renderer/positioning/position-state.test.ts b/src/renderer/positioning/position-state.test.ts index c8c9f10..2113b02 100644 --- a/src/renderer/positioning/position-state.test.ts +++ b/src/renderer/positioning/position-state.test.ts @@ -38,6 +38,7 @@ function createContext(subtitleHeight: number) { state: { currentYPercent: null, persistedSubtitlePosition: { yPercent: 10 }, + isOverSubtitle: false, }, }; } diff --git a/src/renderer/positioning/position-state.ts b/src/renderer/positioning/position-state.ts index ac88d54..87ccb7f 100644 --- a/src/renderer/positioning/position-state.ts +++ b/src/renderer/positioning/position-state.ts @@ -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( ctx: RendererContext, ): SubtitlePositionController { @@ -98,16 +111,7 @@ export function createInMemorySubtitlePositionController( } function applyYPercent(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`; + applyMarginBottom(ctx, yPercent); } function persistSubtitlePositionPatch(patch: Partial): void { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 40d8f00..4bf2357 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -374,7 +374,8 @@ async function init(): Promise { await keyboardHandlers.setupMpvInputForwarding(); - subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); + const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); + subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); positioning.applyStoredSubtitlePosition( await window.electronAPI.getSubtitlePosition(),