Compare commits

..

1 Commits

172 changed files with 1364 additions and 7142 deletions
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
-24
View File
@@ -1,24 +0,0 @@
type: changed
area: notifications
breaking: true
- Added overlay notifications with a Catppuccin Macchiato stack, a 3-second transient timeout, and persistent long-running job notifications for character dictionary sync.
- Added `notifications.overlayPosition` to place overlay notifications at the top left, top center, or top right; top right remains the default.
- Added a notification history panel (default `Ctrl/Cmd+N`, configurable via `shortcuts.toggleNotificationHistory`) that logs every notification shown during the session; the toggle works whether the overlay or mpv has focus, the panel slides in from the same edge as notifications (right when centered), and entries can be removed individually or cleared.
- Made the overlay error/recovery toast follow the configured `notifications.overlayPosition` instead of always pinning to the top-right corner, and kept the notification stack and history panel side synced from that position before first open so left-side history panels slide in from the left.
- Routed startup tokenization, subtitle annotation, and character dictionary status through queued overlay notifications for `overlay`/`both` instead of falling back to mpv OSD while the overlay loads; queued loading cards are shown before their ready update when both happen before the overlay is ready, and the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
- Preserved character dictionary checking/building/importing/ready phases in overlay notification history and sent those phases to system notifications when `notificationType` is `both`.
- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness.
- Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback.
- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick.
- Stabilized overlay startup notifications so queued progress updates do not replay the card entrance animation or trigger macOS pass-through hover flicker after the loading OSD hands off to overlay notifications.
- Fixed mined-card overlay notifications so `overlay` and `both` modes show generated card thumbnails in both live cards and the notification history panel.
- Added Open in Anki buttons to mined-card overlay notifications and their history entries, with a direct AnkiConnect fallback when the live integration is unavailable.
- Fixed those Open in Anki buttons so their fallback honors runtime AnkiConnect URL overrides and the default AnkiConnect endpoint.
- Added an Update button to overlay update-available notifications so users can start the app update flow from the notification.
- Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast.
- Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays.
- Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video.
- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`.
- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected.
+1
View File
@@ -2,4 +2,5 @@ type: changed
area: stats area: stats
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback. - Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
- Refresh anime detail and library cover art immediately after manually changing an AniList entry. - Refresh anime detail and library cover art immediately after manually changing an AniList entry.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed startup pause-until-ready so SubMiner releases playback after tokenization and overlay content are ready even when playback starts before the first subtitle line.
-9
View File
@@ -1,9 +0,0 @@
type: fixed
area: overlay
- Fixed visible overlay startup/resume so subtitle bars can be hovered and clicked as soon as the first subtitle line appears, without waiting for the next subtitle update.
- Released playback after the first overlay measurement instead of waiting for cold subtitle annotation warmup, so overlay notifications and subtitle controls do not freeze during visible-overlay startup.
- Primed Linux overlay input from the first measured subtitle/notification surface before playback resumes, so first-line subtitles and startup notifications are clickable immediately.
- Restored visible-overlay loading feedback as an mpv OSD spinner that stops once the overlay is content-ready and visible.
- Starts that OSD spinner when mpv connects, opens media, or the visible overlay is requested, so cold startup shows feedback before the overlay is almost ready.
- Shows an immediate plugin-side mpv OSD on `start-file` for visible overlay startup, even when normal plugin status OSD messages are disabled or the launcher owns the overlay start, and keeps it spinning until Electron reports the visible overlay is content-ready.
+3 -13
View File
@@ -172,19 +172,10 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
// ==========================================
// Notifications
// Overlay notification display behavior.
// Hot-reload: position changes apply to the next overlay notification.
// ==========================================
"notifications": {
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -208,8 +199,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -549,7 +539,7 @@
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false "overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"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": {
+2 -6
View File
@@ -216,15 +216,11 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
"overwriteImage": true, // replace existing image, or append "overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content "mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected "autoUpdateNewCards": true, // auto-update when new card detected
"notificationType": "overlay" // "overlay", "system", "both", or "none" "notificationType": "osd" // "osd", "system", "both", or "none"
} }
} }
``` ```
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged. `overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
## AI Translation ## AI Translation
@@ -355,7 +351,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
"overwriteImage": true, "overwriteImage": true,
"mediaInsertMode": "append", "mediaInsertMode": "append",
"autoUpdateNewCards": true, "autoUpdateNewCards": true,
"notificationType": "overlay", "notificationType": "osd",
}, },
"ai": { "ai": {
"enabled": false, "enabled": false,
-2
View File
@@ -158,8 +158,6 @@ The three collapsible sections can be configured to start open or closed:
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
**Phases:** **Phases:**
1. **checking** - Is there already a cached snapshot for this media ID? 1. **checking** - Is there already a cached snapshot for this media ID?
+8 -37
View File
@@ -158,7 +158,6 @@ The configuration file includes several main sections:
- [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode - [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing - [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
- [**Notifications**](#notifications) - Overlay notification placement
## Core Settings ## Core Settings
@@ -204,38 +203,12 @@ Configure automatic update checks and update notifications:
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. | | `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. | | `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. `"both"` means overlay + system. | | `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior.
### Notifications
Configure where overlay notification cards appear:
```json
{
"notifications": {
"overlayPosition": "top-right"
}
}
```
| Option | Values | Description |
| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ |
| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. |
#### Notification history panel
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`.
### Auto-Start Overlay ### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv: Control whether the overlay automatically becomes visible when it connects to mpv:
@@ -250,7 +223,7 @@ Control whether the overlay automatically becomes visible when it connects to mp
| -------------------- | --------------- | ----------------------------------------------------- | | -------------------- | --------------- | ----------------------------------------------------- |
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) |
When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness. When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`. On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`.
@@ -647,14 +620,13 @@ See `config.example.jsonc` for detailed configuration options.
"openControllerDebug": "Alt+Shift+C", "openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "Backslash", "toggleSubtitleSidebar": "Backslash",
"toggleNotificationHistory": "CommandOrControl+N",
"multiCopyTimeoutMs": 3000 "multiCopyTimeoutMs": 3000
} }
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
@@ -673,7 +645,6 @@ See `config.example.jsonc` for detailed configuration options.
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | | `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
**See `config.example.jsonc`** for the complete list of shortcut configuration options. **See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -973,7 +944,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -1018,7 +989,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). | | `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
@@ -1488,14 +1459,14 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) | | `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) | | `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) | | `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) | | `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) | | `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) | | `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) | | `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
+2
View File
@@ -50,6 +50,8 @@ Cover-art library with search and sorting, per-series progress, episode drill-do
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card. Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
+3 -3
View File
@@ -1,6 +1,6 @@
# MPV Plugin # MPV Plugin
**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window. **What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window.
**Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner. **Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner.
@@ -30,7 +30,7 @@ input-ipc-server=\\.\pipe\subminer-socket
Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"): Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"):
| Chord | Action | | Chord | Action |
| --------------- | -------------------------------------- | | ---------------- | -------------------------------------- |
| `y-y` | Open menu | | `y-y` | Open menu |
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
@@ -166,7 +166,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow). For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay. - **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback). - **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts). - **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. - **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
+3 -13
View File
@@ -172,19 +172,10 @@
"updates": { "updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
// ==========================================
// Notifications
// Overlay notification display behavior.
// Hot-reload: position changes apply to the next overlay notification.
// ==========================================
"notifications": {
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
@@ -208,8 +199,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -549,7 +539,7 @@
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false "overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "overlay", // Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"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": {
-1
View File
@@ -82,7 +82,6 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
+1 -1
View File
@@ -126,7 +126,7 @@ The detected launcher is installed in a protected path such as `/usr/local/bin/s
**OSD update notification did not appear** **OSD update notification did not appear**
`updates.notificationType: "osd"` uses the legacy mpv OSD path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` for OS notifications, `"both"` for overlay + OS notifications, or `"osd-system"` in `config.jsonc` if you want the legacy OSD + OS combination. `updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
## AnkiConnect ## AnkiConnect
@@ -64,25 +64,6 @@ prefetch work and re-centers prefetch around the live playback time.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written. written.
## Startup Ready Release
- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before
releasing the mpv startup gate.
- Visible-overlay startup creates the tray and visible overlay shell before tokenization and
annotation warmups continue. Cold `--start --background --managed-playback` launches still handle
initial args before the deferred Yomitan wait.
- Overlay-routed startup notifications are queued in the main process until an overlay window has
finished loading. Progress notifications with the same id are upserted so spinner ticks do not
flood a cold-start overlay, while events with distinct history ids are retained for phase-level
history such as character dictionary checking/building/importing.
- The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that
window so readiness can still arrive before fallback resumes playback.
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
waits for a fresh measured subtitle rectangle before signaling readiness.
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
a later subtitle event that cannot happen while mpv is paused.
## Linux/X11 Window Shape ## Linux/X11 Window Shape
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with - `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
@@ -1,29 +0,0 @@
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
# Early Managed Overlay Startup Design
Status: approved
Date: 2026-06-06
## Problem
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
overlay surface and fall back to non-overlay status paths.
## Chosen Approach
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
while the existing tokenization warmups continue in the background.
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
second-instance attach behavior remains unchanged: when the launcher finds an already-running
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
## Checks
- Add a startup ordering regression test for managed background playback.
- Keep the existing deferred startup ordering for non-managed launches.
- Run the startup/runtime test slice plus SubMiner verification lane.
@@ -1,27 +0,0 @@
<!-- read_when: changing overlay notification hover, macOS mouse passthrough, or notification actions -->
# macOS Notification Hover Stability Design
Status: approved
Date: 2026-06-09
## Problem
On macOS, hovering a character dictionary build notification can make the card flicker and slide as
if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay
window's mouse-passthrough state for a progress card that has no user action.
## Chosen Approach
Keep non-action overlay notifications visually stable and click-through on hover. Only notifications
with explicit actions should request interactive overlay input. The notification history panel keeps
its existing interactive behavior.
This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while
preserving clickable notification actions.
## Checks
- Add a renderer regression test for passive notification hover.
- Keep action-bearing notification cards interactive.
- Run the targeted overlay notification and mouse-ignore tests.
@@ -45,7 +45,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
+1 -20
View File
@@ -82,7 +82,6 @@ function createContext(): LauncherCommandContext {
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
@@ -210,7 +209,6 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
autoStart: true, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false, autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
@@ -274,7 +272,6 @@ test('plugin auto-start playback attaches a warm background app through the laun
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
@@ -344,14 +341,12 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
}; };
let availabilityConfigDir: string | undefined; let availabilityConfigDir: string | undefined;
let overlayConfigDir: string | undefined; let overlayConfigDir: string | undefined;
let overlayLoadingOsd: boolean | undefined;
try { try {
process.env.XDG_CONFIG_HOME = xdgConfigHome; process.env.XDG_CONFIG_HOME = xdgConfigHome;
@@ -362,19 +357,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {}, checkDependencies: () => {},
registerCleanup: () => {}, registerCleanup: () => {},
startMpv: async ( startMpv: async () => {},
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
overlayLoadingOsd = (
options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined
)?.overlayLoadingOsd;
},
waitForUnixSocketReady: async () => true, waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
overlayConfigDir = configDir; overlayConfigDir = configDir;
@@ -391,7 +374,6 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
assert.equal(availabilityConfigDir, expectedConfigDir); assert.equal(availabilityConfigDir, expectedConfigDir);
assert.equal(overlayConfigDir, expectedConfigDir); assert.equal(overlayConfigDir, expectedConfigDir);
assert.equal(overlayLoadingOsd, true);
} finally { } finally {
if (originalXdgConfigHome === undefined) { if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME; delete process.env.XDG_CONFIG_HOME;
@@ -421,7 +403,6 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
-9
View File
@@ -232,14 +232,6 @@ export async function runPlaybackCommandWithDeps(
? { ...pluginRuntimeConfig, autoStart: false } ? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig; : pluginRuntimeConfig;
const shouldShowOverlayLoadingOsd =
!isAppOwnedYoutubeFlow &&
(pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) &&
(pluginRuntimeConfig.autoStart ||
args.startOverlay ||
args.autoStartOverlay ||
shouldLauncherAttachRunningApp);
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay && pluginRuntimeConfig.autoStartVisibleOverlay &&
@@ -274,7 +266,6 @@ export async function runPlaybackCommandWithDeps(
} }
: {}), : {}),
backend: args.backend, backend: args.backend,
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
}, },
}, },
-27
View File
@@ -129,11 +129,6 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => { test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({ const parsed = parsePluginRuntimeConfigFromMainConfig({
auto_start_overlay: false, auto_start_overlay: false,
ankiConnect: {
behavior: {
notificationType: 'osd-system',
},
},
texthooker: { texthooker: {
launchAtStartup: false, launchAtStartup: false,
}, },
@@ -153,32 +148,18 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
assert.equal(parsed.autoStart, true); assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true); assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, true);
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage'); assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(parsed.texthookerEnabled, false); assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, false); assert.equal(parsed.aniskipEnabled, false);
assert.equal(parsed.aniskipButtonKey, 'F8'); assert.equal(parsed.aniskipButtonKey, 'F8');
}); });
test('parsePluginRuntimeConfigFromMainConfig disables plugin osd messages for overlay notification routing', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig({
ankiConnect: {
behavior: {
notificationType: 'both',
},
},
});
assert.equal(parsed.osdMessages, false);
});
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => { test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
const parsed = parsePluginRuntimeConfigFromMainConfig(null); const parsed = parsePluginRuntimeConfigFromMainConfig(null);
assert.equal(parsed.autoStart, true); assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, true); assert.equal(parsed.autoStartPauseUntilReady, true);
assert.equal(parsed.osdMessages, false);
assert.equal(parsed.texthookerEnabled, false); assert.equal(parsed.texthookerEnabled, false);
assert.equal(parsed.aniskipEnabled, true); assert.equal(parsed.aniskipEnabled, true);
assert.equal(parsed.aniskipButtonKey, 'TAB'); assert.equal(parsed.aniskipButtonKey, 'TAB');
@@ -194,7 +175,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
autoStart: true, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: true,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: false, aniskipEnabled: false,
aniskipButtonKey: 'F8', aniskipButtonKey: 'F8',
@@ -207,10 +187,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=yes',
'subminer-texthooker_enabled=no', 'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no', 'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8', 'subminer-aniskip_button_key=F8',
@@ -228,7 +205,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
autoStart: true, autoStart: true,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: false, aniskipEnabled: false,
aniskipButtonKey: 'F8,\nF9', aniskipButtonKey: 'F8,\nF9',
@@ -241,10 +217,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
'subminer-backend=x11', 'subminer-backend=x11',
'subminer-auto_start=yes', 'subminer-auto_start=yes',
'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_visible_overlay=no',
'subminer-overlay_loading_osd=no',
'subminer-auto_start_pause_until_ready=yes', 'subminer-auto_start_pause_until_ready=yes',
'subminer-auto_start_pause_until_ready_timeout_seconds=30',
'subminer-osd_messages=no',
'subminer-texthooker_enabled=no', 'subminer-texthooker_enabled=no',
'subminer-aniskip_enabled=no', 'subminer-aniskip_enabled=no',
'subminer-aniskip_button_key=F8 F9', 'subminer-aniskip_button_key=F8 F9',
+1 -7
View File
@@ -22,11 +22,6 @@ function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
return trimmed.length > 0 ? trimmed : fallback; return trimmed.length > 0 ? trimmed : fallback;
} }
function pluginOsdMessagesFromNotificationType(root: Record<string, unknown> | null): boolean {
const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType;
return notificationType === 'osd' || notificationType === 'osd-system';
}
function validBackendOrDefault(value: unknown, fallback: Backend): Backend { function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
if (typeof value !== 'string') return fallback; if (typeof value !== 'string') return fallback;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
@@ -58,7 +53,6 @@ export function parsePluginRuntimeConfigFromMainConfig(
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
osdMessages: pluginOsdMessagesFromNotificationType(root),
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false), texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true), aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'), aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
@@ -78,7 +72,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, osd_messages=${parsed.osdMessages}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`, `Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
); );
return parsed; return parsed;
} }
-2
View File
@@ -387,7 +387,6 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: true, aniskipEnabled: true,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
@@ -406,7 +405,6 @@ test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin Ani
autoStart: true, autoStart: true,
autoStartVisibleOverlay: true, autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true, autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false, texthookerEnabled: false,
aniskipEnabled: false, aniskipEnabled: false,
aniskipButtonKey: 'TAB', aniskipButtonKey: 'TAB',
-2
View File
@@ -209,8 +209,6 @@ export interface PluginRuntimeConfig {
autoStart: boolean; autoStart: boolean;
autoStartVisibleOverlay: boolean; autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean; autoStartPauseUntilReady: boolean;
overlayLoadingOsd?: boolean;
osdMessages: boolean;
texthookerEnabled: boolean; texthookerEnabled: boolean;
aniskipEnabled: boolean; aniskipEnabled: boolean;
aniskipButtonKey: string; aniskipButtonKey: string;
-2
View File
@@ -467,9 +467,7 @@ function M.create(ctx)
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
local message = string.format(opts.aniskip_button_text, key) local message = string.format(opts.aniskip_button_text, key)
if opts.osd_messages then
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3) mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
end
state.aniskip.prompt_shown = true state.aniskip.prompt_shown = true
end end
end end
-24
View File
@@ -112,14 +112,6 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_overlay_loading_osd_enabled()
local raw_overlay_loading_osd = opts.overlay_loading_osd
if raw_overlay_loading_osd == nil then
raw_overlay_loading_osd = opts["overlay-loading-osd"]
end
return options_helper.coerce_bool(raw_overlay_loading_osd, false)
end
local function next_auto_start_retry_generation() local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation return state.auto_start_retry_generation
@@ -159,14 +151,6 @@ function M.create(ctx)
and not (state.overlay_running and state.auto_play_ready_signal_seen == true) and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
end end
local function should_show_overlay_loading_osd()
return (
resolve_overlay_loading_osd_enabled()
or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled())
)
and not state.suppress_ready_overlay_restore
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then if generation ~= state.auto_start_retry_generation then
return return
@@ -194,7 +178,6 @@ function M.create(ctx)
.. process.describe_mpv_ipc_socket_match(opts.socket_path) .. process.describe_mpv_ipc_socket_match(opts.socket_path)
.. ")" .. ")"
) )
process.stop_overlay_loading_osd()
schedule_aniskip_fetch("file-loaded", 0) schedule_aniskip_fetch("file-loaded", 0)
return return
end end
@@ -209,9 +192,6 @@ function M.create(ctx)
end end
local function on_start_file() local function on_start_file()
if should_show_overlay_loading_osd() then
process.start_overlay_loading_osd()
end
if state.pending_reload_media_identity ~= nil then if state.pending_reload_media_identity ~= nil then
local media_identity = resolve_media_identity() local media_identity = resolve_media_identity()
if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then
@@ -265,7 +245,6 @@ function M.create(ctx)
end end
if same_media_reload then if same_media_reload then
process.stop_overlay_loading_osd()
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.app_managed_playback_active then if state.app_managed_playback_active then
return return
@@ -294,7 +273,6 @@ function M.create(ctx)
end end
if state.app_managed_playback_active then if state.app_managed_playback_active then
process.stop_overlay_loading_osd()
subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload") subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload")
return return
end end
@@ -313,7 +291,6 @@ function M.create(ctx)
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
process.stop_overlay_loading_osd()
clear_pending_visible_overlay_hide() clear_pending_visible_overlay_hide()
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
state.current_media_identity = nil state.current_media_identity = nil
@@ -333,7 +310,6 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function(event) mp.register_event("end-file", function(event)
process.stop_overlay_loading_osd()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
local reason = type(event) == "table" and event.reason or nil local reason = type(event) == "table" and event.reason or nil
+2 -2
View File
@@ -43,8 +43,8 @@ function M.create(ctx)
end end
end end
local function show_osd(message, options) local function show_osd(message)
if opts.osd_messages or (options and options.force == true) then if opts.osd_messages then
local payload = "SubMiner: " .. message local payload = "SubMiner: " .. message
local sent = false local sent = false
if type(mp.osd_message) == "function" then if type(mp.osd_message) == "function" then
-6
View File
@@ -2,7 +2,6 @@ local M = {}
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local opts = ctx.opts
local process = ctx.process local process = ctx.process
local aniskip = ctx.aniskip local aniskip = ctx.aniskip
local hover = ctx.hover local hover = ctx.hover
@@ -44,9 +43,6 @@ function M.create(ctx)
mp.register_script_message("subminer-autoplay-ready", function() mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready() process.notify_auto_play_ready()
end) end)
mp.register_script_message("subminer-overlay-loading-ready", function()
process.stop_overlay_loading_osd()
end)
mp.register_script_message("subminer-aniskip-refresh", function() mp.register_script_message("subminer-aniskip-refresh", function()
aniskip.fetch_aniskip_for_current_media("script-message") aniskip.fetch_aniskip_for_current_media("script-message")
end) end)
@@ -60,9 +56,7 @@ function M.create(ctx)
hover.handle_hover_message(payload_json) hover.handle_hover_message(payload_json)
end) end)
mp.register_script_message("subminer-stats-toggle", function() mp.register_script_message("subminer-stats-toggle", function()
if opts.osd_messages then
mp.osd_message("Stats: press ` (backtick) in overlay", 3) mp.osd_message("Stats: press ` (backtick) in overlay", 3)
end
end) end)
mp.register_script_message("subminer-reload-session-bindings", function() mp.register_script_message("subminer-reload-session-bindings", function()
ctx.session_bindings.reload_bindings() ctx.session_bindings.reload_bindings()
+1 -2
View File
@@ -32,10 +32,9 @@ function M.load(options_lib, default_socket_path)
backend = "auto", backend = "auto",
auto_start = false, auto_start = false,
auto_start_visible_overlay = false, auto_start_visible_overlay = false,
overlay_loading_osd = false,
auto_start_pause_until_ready = true, auto_start_pause_until_ready = true,
auto_start_pause_until_ready_owns_initial_pause = false, auto_start_pause_until_ready_owns_initial_pause = false,
auto_start_pause_until_ready_timeout_seconds = 30, auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = false, aniskip_enabled = false,
+3 -62
View File
@@ -4,12 +4,9 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25 local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
function M.create(ctx) function M.create(ctx)
@@ -56,14 +53,6 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false) return options_helper.coerce_bool(raw_pause_until_ready, false)
end end
local function resolve_osd_messages_enabled()
local raw_osd_messages = opts.osd_messages
if raw_osd_messages == nil then
raw_osd_messages = opts["osd-messages"]
end
return options_helper.coerce_bool(raw_osd_messages, false)
end
local function resolve_pause_until_ready_owns_initial_pause() local function resolve_pause_until_ready_owns_initial_pause()
local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause
if raw_owns_initial_pause == nil then if raw_owns_initial_pause == nil then
@@ -257,42 +246,6 @@ function M.create(ctx)
state.auto_play_ready_osd_timer = nil state.auto_play_ready_osd_timer = nil
end end
local function clear_overlay_loading_osd_timer()
local timer = state.overlay_loading_osd_timer
if timer and timer.kill then
timer:kill()
end
state.overlay_loading_osd_timer = nil
end
local function stop_overlay_loading_osd()
state.overlay_loading_osd_active = false
state.overlay_loading_osd_frame = 1
clear_overlay_loading_osd_timer()
end
local function start_overlay_loading_osd()
if state.overlay_loading_osd_active then
return
end
state.overlay_loading_osd_active = true
state.overlay_loading_osd_frame = 1
local function show_next_overlay_loading_frame()
local frame_index = state.overlay_loading_osd_frame or 1
local frame = OVERLAY_LOADING_OSD_FRAMES[frame_index] or OVERLAY_LOADING_OSD_FRAMES[1]
show_osd(OVERLAY_LOADING_OSD_PREFIX .. frame, { force = true })
state.overlay_loading_osd_frame = (frame_index % #OVERLAY_LOADING_OSD_FRAMES) + 1
end
show_next_overlay_loading_frame()
if type(mp.add_periodic_timer) == "function" then
state.overlay_loading_osd_timer = mp.add_periodic_timer(OVERLAY_LOADING_OSD_REFRESH_SECONDS, function()
if state.overlay_loading_osd_active then
show_next_overlay_loading_frame()
end
end)
end
end
local function disarm_auto_play_ready_gate(options) local function disarm_auto_play_ready_gate(options)
local should_resume = options == nil or options.resume_playback ~= false local should_resume = options == nil or options.resume_playback ~= false
local was_armed = state.auto_play_ready_gate_armed local was_armed = state.auto_play_ready_gate_armed
@@ -311,11 +264,8 @@ function M.create(ctx)
return false return false
end end
local should_resume_playback = state.auto_play_ready_should_resume_playback == true local should_resume_playback = state.auto_play_ready_should_resume_playback == true
if resolve_osd_messages_enabled() then
stop_overlay_loading_osd()
show_osd(AUTO_PLAY_READY_READY_OSD)
end
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
show_osd(AUTO_PLAY_READY_READY_OSD)
if should_resume_playback then if should_resume_playback then
mp.set_property_native("pause", false) mp.set_property_native("pause", false)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
@@ -337,11 +287,8 @@ function M.create(ctx)
end end
state.auto_play_ready_gate_armed = true state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
if resolve_osd_messages_enabled() then
stop_overlay_loading_osd()
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
end if type(mp.add_periodic_timer) == "function" then
if resolve_osd_messages_enabled() and type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_osd(AUTO_PLAY_READY_LOADING_OSD)
@@ -596,7 +543,6 @@ function M.create(ctx)
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
stop_overlay_loading_osd()
show_osd("Error: binary not found") show_osd("Error: binary not found")
return return
end end
@@ -681,7 +627,6 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason) subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
stop_overlay_loading_osd()
show_osd("Overlay start failed") show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed") release_auto_play_ready_gate("overlay-start-failed")
return return
@@ -734,7 +679,6 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
state.auto_play_ready_signal_seen = false state.auto_play_ready_signal_seen = false
stop_overlay_loading_osd()
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
show_osd("Stopped") show_osd("Stopped")
end end
@@ -746,7 +690,6 @@ function M.create(ctx)
return return
end end
state.suppress_ready_overlay_restore = true state.suppress_ready_overlay_restore = true
stop_overlay_loading_osd()
run_control_command_async("hide-visible-overlay", nil, function(ok, result) run_control_command_async("hide-visible-overlay", nil, function(ok, result)
if ok then if ok then
@@ -950,8 +893,6 @@ function M.create(ctx)
check_binary_available = check_binary_available, check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready, notify_auto_play_ready = notify_auto_play_ready,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
start_overlay_loading_osd = start_overlay_loading_osd,
stop_overlay_loading_osd = stop_overlay_loading_osd,
} }
end end
-2
View File
@@ -244,8 +244,6 @@ function M.create(ctx)
return { "--toggle-secondary-sub" } return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then elseif action_id == "toggleSubtitleSidebar" then
return { "--toggle-subtitle-sidebar" } return { "--toggle-subtitle-sidebar" }
elseif action_id == "toggleNotificationHistory" then
return { "--session-action", '{"actionId":"toggleNotificationHistory"}' }
elseif action_id == "markAudioCard" then elseif action_id == "markAudioCard" then
return { "--mark-audio-card" } return { "--mark-audio-card" }
elseif action_id == "markWatched" then elseif action_id == "markWatched" then
-3
View File
@@ -35,9 +35,6 @@ function M.new()
auto_play_ready_osd_timer = nil, auto_play_ready_osd_timer = nil,
auto_play_ready_signal_seen = false, auto_play_ready_signal_seen = false,
auto_play_ready_initial_pause_ownership_consumed = false, auto_play_ready_initial_pause_ownership_consumed = false,
overlay_loading_osd_active = false,
overlay_loading_osd_timer = nil,
overlay_loading_osd_frame = 1,
pending_visible_overlay_hide_timer = nil, pending_visible_overlay_hide_timer = nil,
pending_visible_overlay_hide_generation = 0, pending_visible_overlay_hide_generation = 0,
suppress_ready_overlay_restore = false, suppress_ready_overlay_restore = false,
-136
View File
@@ -979,31 +979,6 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "no",
auto_start_visible_overlay = "yes",
overlay_loading_osd = "yes",
osd_messages = false,
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for explicit early overlay loading OSD scenario: " .. tostring(err))
fire_event(recorded, "start-file")
assert_true(
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
"explicit overlay loading OSD option should show spinner even when plugin auto-start is disabled"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1720,91 +1695,6 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
osd_messages = false,
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for early overlay loading OSD scenario: " .. tostring(err))
fire_event(recorded, "start-file")
assert_true(
has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
"auto-start visible overlay should force overlay loading OSD spinner on start-file"
)
assert_true(
#recorded.periodic_timers == 1,
"auto-start visible overlay should refresh the early overlay loading OSD"
)
local overlay_loading_timer = recorded.periodic_timers[1]
recorded.periodic_timers[1].callback()
assert_true(
has_osd_message(recorded.osd, "SubMiner: Overlay loading /"),
"auto-start visible overlay should advance the early overlay loading OSD spinner"
)
fire_event(recorded, "file-loaded")
assert_true(
overlay_loading_timer.killed ~= true,
"autoplay gate should keep forced overlay loading OSD alive while normal plugin OSD messages are disabled"
)
assert_true(
#recorded.periodic_timers == 1,
"autoplay gate should not replace forced overlay loading OSD with a suppressed tokenization OSD timer"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
overlay_loading_timer.killed ~= true,
"autoplay readiness should not stop forced overlay loading OSD before overlay content is ready"
)
overlay_loading_timer.callback()
assert_true(
has_osd_message(recorded.osd, "SubMiner: Overlay loading -"),
"forced overlay loading OSD should keep spinning during the overlay startup gap"
)
assert_true(
recorded.script_messages["subminer-overlay-loading-ready"] ~= nil,
"overlay loading ready script message should be registered"
)
recorded.script_messages["subminer-overlay-loading-ready"]()
assert_true(
recorded.periodic_timers[1].killed == true,
"overlay loading ready should stop the early overlay loading OSD refresher"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "no",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for hidden overlay loading OSD scenario: " .. tostring(err))
fire_event(recorded, "start-file")
assert_true(
not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"),
"auto-start hidden visible overlay should not show early overlay loading OSD"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -2025,32 +1915,6 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
auto_start_pause_until_ready_owns_initial_pause = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for default pause timeout scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_timeout(recorded.timeouts, 30),
"pause-until-ready default timeout should give cold app startup 30 seconds"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
-19
View File
@@ -87,25 +87,6 @@ test('AnkiConnectClient lists decks and note type fields', async () => {
); );
}); });
test('AnkiConnectClient opens a note in the Anki browser', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
};
const calls: Array<{ action: string; params: unknown }> = [];
client.client = {
post: async (_url, body) => {
calls.push({ action: body.action, params: body.params });
return { data: { result: [], error: null } };
},
};
await (
client as unknown as { openNoteInBrowser: (noteId: number) => Promise<void> }
).openNoteInBrowser(12345);
assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]);
});
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => { test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> }; client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
-7
View File
@@ -247,13 +247,6 @@ export class AnkiConnectClient {
return (result as Record<string, unknown>[]) || []; return (result as Record<string, unknown>[]) || [];
} }
async openNoteInBrowser(noteId: number): Promise<void> {
if (!Number.isInteger(noteId) || noteId <= 0) {
throw new Error('Invalid Anki note id');
}
await this.invoke('guiBrowse', { query: `nid:${noteId}` });
}
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> { async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
await this.invoke('updateNoteFields', { await this.invoke('updateNoteFields', {
note: { note: {
-190
View File
@@ -7,14 +7,6 @@ import { AnkiIntegration } from './anki-integration';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import { AnkiConnectConfig } from './types'; import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = {
title: string;
body?: string;
image?: string;
variant?: string;
actions?: Array<{ id: string; label: string; noteId?: number }>;
};
interface IntegrationTestContext { interface IntegrationTestContext {
integration: AnkiIntegration; integration: AnkiIntegration;
calls: { calls: {
@@ -414,188 +406,6 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
}); });
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
const cleanupPaths: string[] = [];
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'both',
},
},
{} as never,
{
currentVideoPath: '/tmp/show.mkv',
currentTimePos: 123.45,
} as never,
undefined,
(title, options) => {
desktopNotifications.push({ title, body: options.body, icon: options.icon });
},
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload as TestOverlayNotificationPayload);
},
);
(
integration as unknown as {
mediaGenerator: {
generateNotificationIcon: (videoPath: string, timestamp: number) => Promise<Buffer>;
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
scheduleNotificationIconCleanup: (filePath: string) => void;
};
}
).mediaGenerator = {
generateNotificationIcon: async (videoPath, timestamp) => {
generatedFrom.push({ videoPath, timestamp });
return Buffer.from('png');
},
writeNotificationIconToFile: (iconBuffer, noteId) => {
assert.equal(iconBuffer.toString(), 'png');
assert.equal(noteId, 42);
return notificationIconPath;
},
scheduleNotificationIconCleanup: (filePath) => {
cleanupPaths.push(filePath);
},
};
await (
integration as unknown as {
showNotification: (noteId: number, label: string | number) => Promise<void>;
}
).showNotification(42, '食べる');
assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
assert.equal(overlayNotifications.length, 1);
assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
assert.equal(
overlayNotifications[0]?.image,
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
);
assert.deepEqual(overlayNotifications[0]?.actions, [
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
]);
assert.deepEqual(desktopNotifications, [
{
title: 'Anki Card Updated',
body: 'Updated card: 食べる',
icon: notificationIconPath,
},
]);
assert.deepEqual(cleanupPaths, [notificationIconPath]);
});
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const cleanupPaths: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'both',
},
},
{} as never,
{
currentVideoPath: '/tmp/show.mkv',
currentTimePos: 123.45,
} as never,
undefined,
(title, options) => {
desktopNotifications.push({ title, body: options.body, icon: options.icon });
},
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload as TestOverlayNotificationPayload);
},
);
(
integration as unknown as {
mediaGenerator: {
generateNotificationIcon: () => Promise<Buffer>;
writeNotificationIconToFile: () => string;
scheduleNotificationIconCleanup: (filePath: string) => void;
};
}
).mediaGenerator = {
generateNotificationIcon: async () => Buffer.from('png'),
writeNotificationIconToFile: () => {
throw new Error('disk full');
},
scheduleNotificationIconCleanup: (filePath) => {
cleanupPaths.push(filePath);
},
};
await (
integration as unknown as {
showNotification: (noteId: number, label: string | number) => Promise<void>;
}
).showNotification(42, '食べる');
assert.equal(
overlayNotifications[0]?.image,
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
);
assert.deepEqual(desktopNotifications, [
{
title: 'Anki Card Updated',
body: 'Updated card: 食べる',
icon: undefined,
},
]);
assert.deepEqual(cleanupPaths, []);
});
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
const osdMessages: string[] = [];
const desktopMessages: string[] = [];
const overlayMessages: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'both',
},
},
{} as never,
{} as never,
(text) => {
osdMessages.push(text);
},
(title, options) => {
desktopMessages.push(`${title}:${options.body ?? ''}`);
},
undefined,
undefined,
{},
undefined,
(payload) => {
overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`);
},
);
assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
assert.deepEqual(osdMessages, []);
assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
});
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator(); const collaborator = createFieldGroupingMergeCollaborator();
+25 -134
View File
@@ -29,8 +29,6 @@ import {
} from './types/anki'; } from './types/anki';
import { AiConfig } from './types/integrations'; import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime'; import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import { import {
@@ -121,15 +119,6 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
); );
} }
function toOverlayNotificationImageSource(iconBuffer: Buffer): string {
return `data:image/png;base64,${iconBuffer.toString('base64')}`;
}
interface NotificationIcon {
filePath?: string;
overlayImageSource: string;
}
export class AnkiIntegration { export class AnkiIntegration {
private client: AnkiConnectClient; private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator; private mediaGenerator: MediaGenerator;
@@ -141,8 +130,6 @@ export class AnkiIntegration {
private osdCallback: ((text: string) => void) | null = null; private osdCallback: ((text: string) => void) | null = null;
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null = private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
null; null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null;
private updateInProgress = false; private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>(); private parseWarningKeys = new Set<string>();
@@ -179,7 +166,6 @@ export class AnkiIntegration {
knownWordCacheStatePath?: string, knownWordCacheStatePath?: string,
aiConfig: AiConfig = {}, aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void, recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -189,7 +175,6 @@ export class AnkiIntegration {
this.mpvClient = mpvClient; this.mpvClient = mpvClient;
this.osdCallback = osdCallback || null; this.osdCallback = osdCallback || null;
this.notificationCallback = notificationCallback || null; this.notificationCallback = notificationCallback || null;
this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null; this.recordCardsMinedCallback = recordCardsMined ?? null;
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
@@ -350,7 +335,7 @@ export class AnkiIntegration {
options, options,
), ),
}, },
showOsdNotification: (text: string) => this.showStatusNotification(text), showOsdNotification: (text: string) => this.showOsdNotification(text),
showUpdateResult: (message: string, success: boolean) => showUpdateResult: (message: string, success: boolean) =>
this.showUpdateResult(message, success), this.showUpdateResult(message, success),
showStatusNotification: (message: string) => this.showStatusNotification(message), showStatusNotification: (message: string) => this.showStatusNotification(message),
@@ -402,7 +387,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
this.withUpdateProgress(initialMessage, action), this.withUpdateProgress(initialMessage, action),
showOsdNotification: (text: string) => this.showStatusNotification(text), showOsdNotification: (text: string) => this.showOsdNotification(text),
findNotes: async (query, options) => findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[], (await this.client.findNotes(query, options)) as number[],
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[], notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
@@ -478,7 +463,7 @@ export class AnkiIntegration {
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showStatusNotification(message), showOsdNotification: (message) => this.showOsdNotification(message),
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage), beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
endUpdateProgress: () => this.endUpdateProgress(), endUpdateProgress: () => this.endUpdateProgress(),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
@@ -525,7 +510,7 @@ export class AnkiIntegration {
}, },
showStatusNotification: (message) => this.showStatusNotification(message), showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showStatusNotification(message), showOsdNotification: (message) => this.showOsdNotification(message),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)), logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
truncateSentence: (sentence) => this.truncateSentence(sentence), truncateSentence: (sentence) => this.truncateSentence(sentence),
@@ -540,10 +525,6 @@ export class AnkiIntegration {
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
} }
async openNoteInAnki(noteId: number): Promise<void> {
await this.client.openNoteInBrowser(noteId);
}
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return ( return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
@@ -879,13 +860,10 @@ export class AnkiIntegration {
private showStatusNotification(message: string): void { private showStatusNotification(message: string): void {
showStatusNotification(message, { showStatusNotification(message, {
getNotificationType: () => this.getNotificationType(), getNotificationType: () => this.config.behavior?.notificationType,
showOsd: (text: string) => { showOsd: (text: string) => {
this.showOsdNotification(text); this.showOsdNotification(text);
}, },
showOverlayNotification: (payload) => {
this.overlayNotificationCallback?.(payload);
},
showSystemNotification: (title: string, options: NotificationOptions) => { showSystemNotification: (title: string, options: NotificationOptions) => {
if (this.notificationCallback) { if (this.notificationCallback) {
this.notificationCallback(title, options); this.notificationCallback(title, options);
@@ -894,51 +872,19 @@ export class AnkiIntegration {
}); });
} }
private getNotificationType(): NotificationType {
return this.config.behavior?.notificationType ?? 'osd';
}
private shouldUseOsdNotifications(): boolean {
const type = this.getNotificationType();
return type === 'osd' || type === 'osd-system';
}
private shouldUseOverlayNotifications(): boolean {
const type = this.getNotificationType();
return type === 'overlay' || type === 'both';
}
private beginUpdateProgress(initialMessage: string): void { private beginUpdateProgress(initialMessage: string): void {
if (!this.shouldUseOsdNotifications()) {
if (this.shouldUseOverlayNotifications()) {
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
});
}
return;
}
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => { beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
this.showOsdNotification(text); this.showOsdNotification(text);
}); });
} }
private endUpdateProgress(): void { private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => { endUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer); clearInterval(timer);
}); });
} }
private clearUpdateProgress(): void { private clearUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
clearUpdateProgress(this.uiFeedbackState, (timer) => { clearUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer); clearInterval(timer);
}); });
@@ -948,23 +894,6 @@ export class AnkiIntegration {
initialMessage: string, initialMessage: string,
action: () => Promise<T>, action: () => Promise<T>,
): Promise<T> { ): Promise<T> {
if (!this.shouldUseOsdNotifications()) {
this.updateInProgress = true;
if (this.shouldUseOverlayNotifications()) {
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
});
}
try {
return await action();
} finally {
this.updateInProgress = false;
}
}
return withUpdateProgress( return withUpdateProgress(
this.uiFeedbackState, this.uiFeedbackState,
{ {
@@ -1088,61 +1017,24 @@ export class AnkiIntegration {
? `Updated card: ${label} (${errorSuffix})` ? `Updated card: ${label} (${errorSuffix})`
: `Updated card: ${label}`; : `Updated card: ${label}`;
const type = this.getNotificationType(); const type = this.config.behavior?.notificationType || 'osd';
if (type === 'osd' || type === 'osd-system') { if (type === 'osd' || type === 'both') {
this.showUpdateResult(message, errorSuffix === undefined); this.showUpdateResult(message, errorSuffix === undefined);
} else { } else {
this.clearUpdateProgress(); this.clearUpdateProgress();
} }
const shouldShowOverlayNotification = if ((type === 'system' || type === 'both') && this.notificationCallback) {
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null; let notificationIconPath: string | undefined;
const shouldShowSystemNotification =
(type === 'system' || type === 'both' || type === 'osd-system') &&
this.notificationCallback !== null;
const notificationIcon =
shouldShowOverlayNotification || shouldShowSystemNotification
? await this.generateNotificationIcon(noteId, shouldShowSystemNotification)
: undefined;
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayNotificationCallback({
id: 'anki-update-progress',
title: 'Anki Card Updated',
body: message,
...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
variant: errorSuffix === undefined ? 'success' : 'error',
persistent: false,
actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
});
}
if (shouldShowSystemNotification && this.notificationCallback) {
this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIcon?.filePath,
});
}
if (notificationIcon) {
if (notificationIcon.filePath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath);
}
}
}
private async generateNotificationIcon(
noteId: number,
shouldWriteToFile: boolean,
): Promise<NotificationIcon | undefined> {
if (!this.mpvClient?.currentVideoPath) {
return undefined;
}
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try { try {
const timestamp = this.mpvClient.currentTimePos || 0; const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); const notificationIconSource = await resolveMediaGenerationInputPath(
this.mpvClient,
'video',
);
if (!notificationIconSource) { if (!notificationIconSource) {
throw new Error('No media source available for notification icon'); throw new Error('No media source available for notification icon');
} }
@@ -1151,26 +1043,25 @@ export class AnkiIntegration {
timestamp, timestamp,
); );
if (iconBuffer && iconBuffer.length > 0) { if (iconBuffer && iconBuffer.length > 0) {
const notificationIcon: NotificationIcon = { notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
overlayImageSource: toOverlayNotificationImageSource(iconBuffer),
};
if (shouldWriteToFile) {
try {
notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile(
iconBuffer, iconBuffer,
noteId, noteId,
); );
} catch (err) {
log.warn('Failed to write notification icon:', (err as Error).message);
}
}
return notificationIcon;
} }
} catch (err) { } catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message); log.warn('Failed to generate notification icon:', (err as Error).message);
} }
}
return undefined; this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIconPath,
});
if (notificationIconPath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
}
}
} }
private showUpdateResult(message: string, success: boolean): void { private showUpdateResult(message: string, success: boolean): void {
@@ -271,28 +271,3 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/); assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
}); });
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
const statusMessages: string[] = [];
const progressMessages: string[] = [];
const { service } = createManualUpdateService({
showOsdNotification: (message) => {
statusMessages.push(message);
},
withUpdateProgress: async (message, action) => {
progressMessages.push(message);
return await action();
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(progressMessages, ['Creating sentence card']);
assert.deepEqual(statusMessages, []);
});
+1
View File
@@ -511,6 +511,7 @@ export class CardCreationService {
endTime = startTime + maxMediaDuration; endTime = startTime + maxMediaDuration;
} }
this.deps.showOsdNotification('Creating sentence card...');
try { try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => { return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
+1 -56
View File
@@ -1,10 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict';
import { import {
beginUpdateProgress, beginUpdateProgress,
createUiFeedbackState, createUiFeedbackState,
showProgressTick, showProgressTick,
showStatusNotification,
showUpdateResult, showUpdateResult,
} from './ui-feedback'; } from './ui-feedback';
@@ -66,57 +65,3 @@ test('showUpdateResult renders failed updates with an x marker', () => {
'x Sentence card failed: deck missing', 'x Sentence card failed: deck missing',
]); ]);
}); });
test('showStatusNotification falls back to system when overlay delivery is unavailable', () => {
const calls: string[] = [];
showStatusNotification('Waiting for card update', {
getNotificationType: () => 'overlay',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showSystemNotification: (title, options) => {
calls.push(`system:${title}:${options.body}`);
},
});
assert.deepEqual(calls, ['system:SubMiner:Waiting for card update']);
});
test('showStatusNotification defaults to mpv osd when notification type is unset', () => {
const calls: string[] = [];
showStatusNotification('Card updated', {
getNotificationType: () => undefined,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.body}`);
},
showSystemNotification: (title, options) => {
calls.push(`system:${title}:${options.body}`);
},
});
assert.deepEqual(calls, ['osd:Card updated']);
});
test('showStatusNotification does not duplicate system notifications for both', () => {
const calls: string[] = [];
showStatusNotification('Card updated', {
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.body}`);
},
showSystemNotification: (title, options) => {
calls.push(`system:${title}:${options.body}`);
},
});
assert.deepEqual(calls, ['overlay:Card updated', 'system:SubMiner:Card updated']);
});
+5 -23
View File
@@ -1,5 +1,4 @@
import type { NotificationOptions } from '../types/anki'; import { NotificationOptions } from '../types/anki';
import type { NotificationType, OverlayNotificationPayload } from '../types/notification';
export interface UiFeedbackState { export interface UiFeedbackState {
progressDepth: number; progressDepth: number;
@@ -14,9 +13,8 @@ export interface UiFeedbackResult {
} }
export interface UiFeedbackNotificationContext { export interface UiFeedbackNotificationContext {
getNotificationType: () => NotificationType | undefined; getNotificationType: () => string | undefined;
showOsd: (text: string) => void; showOsd: (text: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showSystemNotification: (title: string, options: NotificationOptions) => void; showSystemNotification: (title: string, options: NotificationOptions) => void;
} }
@@ -38,29 +36,13 @@ export function showStatusNotification(
message: string, message: string,
context: UiFeedbackNotificationContext, context: UiFeedbackNotificationContext,
): void { ): void {
const type = context.getNotificationType() ?? 'osd'; const type = context.getNotificationType() || 'osd';
if (type === 'none') { if (type === 'osd' || type === 'both') {
return;
}
if (type === 'overlay' || type === 'both') {
if (context.showOverlayNotification) {
context.showOverlayNotification({
title: 'SubMiner',
body: message,
variant: 'info',
});
} else if (type === 'overlay') {
context.showSystemNotification('SubMiner', { body: message });
}
}
if (type === 'osd' || type === 'osd-system') {
context.showOsd(message); context.showOsd(message);
} }
if (type === 'system' || type === 'both' || type === 'osd-system') { if (type === 'system' || type === 'both') {
context.showSystemNotification('SubMiner', { body: message }); context.showSystemNotification('SubMiner', { body: message });
} }
} }
+3 -67
View File
@@ -98,7 +98,6 @@ test('loads defaults when config is missing', () => {
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'transparent'); assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
@@ -173,7 +172,7 @@ test('parses updates config and warns on invalid values', () => {
"updates": { "updates": {
"enabled": false, "enabled": false,
"checkIntervalHours": 6, "checkIntervalHours": 6,
"notificationType": "osd-system", "notificationType": "both",
"channel": "prerelease" "channel": "prerelease"
} }
}`, }`,
@@ -183,7 +182,7 @@ test('parses updates config and warns on invalid values', () => {
const validService = new ConfigService(validDir); const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().updates.enabled, false); assert.equal(validService.getConfig().updates.enabled, false);
assert.equal(validService.getConfig().updates.checkIntervalHours, 6); assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
assert.equal(validService.getConfig().updates.notificationType, 'osd-system'); assert.equal(validService.getConfig().updates.notificationType, 'both');
assert.equal(validService.getConfig().updates.channel, 'prerelease'); assert.equal(validService.getConfig().updates.channel, 'prerelease');
const invalidDir = makeTempDir(); const invalidDir = makeTempDir();
@@ -213,69 +212,6 @@ test('parses updates config and warns on invalid values', () => {
assert.ok(warnings.some((warning) => warning.path === 'updates.channel')); assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
}); });
test('accepts overlay notification config values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"updates": {
"notificationType": "overlay"
},
"ankiConnect": {
"behavior": {
"notificationType": "osd-system"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().updates.notificationType, 'overlay');
assert.equal(service.getConfig().ankiConnect.behavior.notificationType, 'osd-system');
assert.deepEqual(service.getWarnings(), []);
});
test('parses overlay notification position config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"notifications": {
"overlayPosition": "top-left"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().notifications.overlayPosition, 'top-left');
assert.deepEqual(validService.getWarnings(), []);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"notifications": {
"overlayPosition": "bottom-right"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().notifications.overlayPosition,
DEFAULT_CONFIG.notifications.overlayPosition,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'notifications.overlayPosition'),
);
});
test('throws actionable startup parse error for malformed config at construction time', () => { test('throws actionable startup parse error for malformed config at construction time', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
@@ -2814,7 +2750,7 @@ test('template generator includes known keys', () => {
); );
assert.match( assert.match(
output, output,
/"notificationType": "system",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/, /"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
); );
assert.match( assert.match(
output, output,
-2
View File
@@ -34,7 +34,6 @@ const {
subsync, subsync,
startupWarmups, startupWarmups,
updates, updates,
notifications,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
@@ -58,7 +57,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subsync, subsync,
startupWarmups, startupWarmups,
updates, updates,
notifications,
subtitleStyle, subtitleStyle,
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
-5
View File
@@ -15,7 +15,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'subsync' | 'subsync'
| 'startupWarmups' | 'startupWarmups'
| 'updates' | 'updates'
| 'notifications'
| 'auto_start_overlay' | 'auto_start_overlay'
> = { > = {
subtitlePosition: { yPercent: 10 }, subtitlePosition: { yPercent: 10 },
@@ -102,7 +101,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C', openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: 'Backslash', toggleSubtitleSidebar: 'Backslash',
toggleNotificationHistory: 'CommandOrControl+N',
}, },
secondarySub: { secondarySub: {
secondarySubLanguages: [], secondarySubLanguages: [],
@@ -131,8 +129,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
notificationType: 'system', notificationType: 'system',
channel: 'stable', channel: 'stable',
}, },
notifications: {
overlayPosition: 'top-right',
},
auto_start_overlay: true, auto_start_overlay: true,
}; };
@@ -67,7 +67,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
overwriteImage: true, overwriteImage: true,
mediaInsertMode: 'append', mediaInsertMode: 'append',
highlightWord: true, highlightWord: true,
notificationType: 'overlay', notificationType: 'osd',
autoUpdateNewCards: true, autoUpdateNewCards: true,
}, },
nPlusOne: { nPlusOne: {
+2 -22
View File
@@ -1,9 +1,4 @@
import { ResolvedConfig } from '../../types/config'; import { ResolvedConfig } from '../../types/config';
import {
NOTIFICATION_TYPE_VALUES,
OVERLAY_NOTIFICATION_POSITION_VALUES,
SETTINGS_NOTIFICATION_TYPE_VALUES,
} from '../../types/notification';
import { ConfigOptionRegistryEntry } from './shared'; import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry( export function buildCoreConfigOptionRegistry(
@@ -489,11 +484,9 @@ export function buildCoreConfigOptionRegistry(
{ {
path: 'updates.notificationType', path: 'updates.notificationType',
kind: 'enum', kind: 'enum',
enumValues: NOTIFICATION_TYPE_VALUES, enumValues: ['system', 'osd', 'both', 'none'],
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
defaultValue: defaultConfig.updates.notificationType, defaultValue: defaultConfig.updates.notificationType,
description: description: 'How SubMiner announces available updates.',
'How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
}, },
{ {
path: 'updates.channel', path: 'updates.channel',
@@ -502,13 +495,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.updates.channel, defaultValue: defaultConfig.updates.channel,
description: 'Release channel used for update checks.', description: 'Release channel used for update checks.',
}, },
{
path: 'notifications.overlayPosition',
kind: 'enum',
enumValues: OVERLAY_NOTIFICATION_POSITION_VALUES,
defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.',
},
{ {
path: 'shortcuts.multiCopyTimeoutMs', path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number', kind: 'number',
@@ -622,11 +608,5 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar, defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
description: 'Accelerator that toggles the subtitle sidebar visibility.', description: 'Accelerator that toggles the subtitle sidebar visibility.',
}, },
{
path: 'shortcuts.toggleNotificationHistory',
kind: 'string',
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
description: 'Accelerator that toggles the overlay notification history panel.',
},
]; ];
} }
@@ -1,9 +1,5 @@
import { ResolvedConfig } from '../../types/config'; import { ResolvedConfig } from '../../types/config';
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode'; import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
import {
NOTIFICATION_TYPE_VALUES,
SETTINGS_NOTIFICATION_TYPE_VALUES,
} from '../../types/notification';
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared'; import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
export function buildIntegrationConfigOptionRegistry( export function buildIntegrationConfigOptionRegistry(
@@ -162,11 +158,9 @@ export function buildIntegrationConfigOptionRegistry(
{ {
path: 'ankiConnect.behavior.notificationType', path: 'ankiConnect.behavior.notificationType',
kind: 'enum', kind: 'enum',
enumValues: NOTIFICATION_TYPE_VALUES, enumValues: ['osd', 'system', 'both', 'none'],
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
defaultValue: defaultConfig.ankiConnect.behavior.notificationType, defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
description: description: 'Notification surface used to announce mining and update outcomes.',
'Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
}, },
{ {
path: 'ankiConnect.media.syncAnimatedImageToWordAudio', path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
-10
View File
@@ -27,17 +27,7 @@ export interface ConfigOptionRegistryEntry {
kind: ConfigValueKind; kind: ConfigValueKind;
defaultValue: unknown; defaultValue: unknown;
description: string; description: string;
/**
* Complete runtime-valid enum options, including legacy file-config values such as
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
*/
enumValues?: readonly string[]; enumValues?: readonly string[];
/**
* Optional settings UI subset when legacy/runtime-valid enum options should remain
* editable in config files but hidden from new UI choices, for example
* SETTINGS_NOTIFICATION_TYPE_VALUES.
*/
settingsEnumValues?: readonly string[];
runtime?: RuntimeOptionRegistryEntry; runtime?: RuntimeOptionRegistryEntry;
} }
@@ -63,12 +63,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
], ],
key: 'updates', key: 'updates',
}, },
{
title: 'Notifications',
description: ['Overlay notification display behavior.'],
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications',
},
{ {
title: 'Keyboard Shortcuts', title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+8 -24
View File
@@ -1,12 +1,7 @@
import { DEFAULT_CONFIG } from '../definitions'; import { DEFAULT_CONFIG } from '../definitions';
import type { ResolveContext } from './context'; import type { ResolveContext } from './context';
import { isNotificationType, type NotificationType } from '../../types/notification';
import { asBoolean, asColor, asNumber, asString, isObject } from './shared'; import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
function asNotificationType(value: unknown): NotificationType | undefined {
return isNotificationType(value) ? value : undefined;
}
export function applyAnkiConnectResolution(context: ResolveContext): void { export function applyAnkiConnectResolution(context: ResolveContext): void {
if (!isObject(context.src.ankiConnect)) { if (!isObject(context.src.ankiConnect)) {
return; return;
@@ -47,8 +42,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'notificationType', 'notificationType',
'autoUpdateNewCards', 'autoUpdateNewCards',
]); ]);
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const { const {
knownWords: _knownWordsConfigFromAnkiConnect, knownWords: _knownWordsConfigFromAnkiConnect,
@@ -106,22 +99,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}, },
}; };
if (hasOwn(behavior, 'notificationType')) {
const parsed = asNotificationType(behavior.notificationType);
if (parsed === undefined) {
context.resolved.ankiConnect.behavior.notificationType =
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
context.warn(
'ankiConnect.behavior.notificationType',
behavior.notificationType,
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
} else {
context.resolved.ankiConnect.behavior.notificationType = parsed;
}
}
if (isObject(ac.isLapis)) { if (isObject(ac.isLapis)) {
const lapisEnabled = asBoolean(ac.isLapis.enabled); const lapisEnabled = asBoolean(ac.isLapis.enabled);
if (lapisEnabled !== undefined) { if (lapisEnabled !== undefined) {
@@ -312,6 +289,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
} }
const legacy = ac as Record<string, unknown>; const legacy = ac as Record<string, unknown>;
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
const parsed = asNumber(value); const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) { if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
@@ -349,6 +328,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => { const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
return value === 'append' || value === 'prepend' ? value : undefined; return value === 'append' || value === 'prepend' ? value : undefined;
}; };
const asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => {
return value === 'osd' || value === 'system' || value === 'both' || value === 'none'
? value
: undefined;
};
const mapLegacy = <T>( const mapLegacy = <T>(
key: string, key: string,
parse: (value: unknown) => T | undefined, parse: (value: unknown) => T | undefined,
@@ -649,7 +633,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.behavior.notificationType = value; context.resolved.ankiConnect.behavior.notificationType = value;
}, },
context.resolved.ankiConnect.behavior.notificationType, context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", "Expected 'osd', 'system', 'both', or 'none'.",
); );
} }
if (!hasOwn(behavior, 'autoUpdateNewCards')) { if (!hasOwn(behavior, 'autoUpdateNewCards')) {
+7 -18
View File
@@ -1,6 +1,5 @@
import { ResolveContext } from './context'; import { ResolveContext } from './context';
import { applyControllerConfig } from './controller'; import { applyControllerConfig } from './controller';
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
import { asBoolean, asNumber, asString, isObject } from './shared'; import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyCoreDomainConfig(context: ResolveContext): void { export function applyCoreDomainConfig(context: ResolveContext): void {
@@ -195,14 +194,19 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
} }
const notificationType = asString(src.updates.notificationType); const notificationType = asString(src.updates.notificationType);
if (isNotificationType(notificationType)) { if (
notificationType === 'system' ||
notificationType === 'osd' ||
notificationType === 'both' ||
notificationType === 'none'
) {
resolved.updates.notificationType = notificationType; resolved.updates.notificationType = notificationType;
} else if (src.updates.notificationType !== undefined) { } else if (src.updates.notificationType !== undefined) {
warn( warn(
'updates.notificationType', 'updates.notificationType',
src.updates.notificationType, src.updates.notificationType,
resolved.updates.notificationType, resolved.updates.notificationType,
'Expected overlay, system, both, none, osd, or osd-system.', 'Expected system, osd, both, or none.',
); );
} }
@@ -236,7 +240,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager', 'openCharacterDictionaryManager',
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'toggleNotificationHistory',
] as const; ] as const;
for (const key of shortcutKeys) { for (const key of shortcutKeys) {
@@ -320,18 +323,4 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
resolved.subtitlePosition.yPercent = y; resolved.subtitlePosition.yPercent = y;
} }
} }
if (isObject(src.notifications)) {
const overlayPosition = asString(src.notifications.overlayPosition);
if (isOverlayNotificationPosition(overlayPosition)) {
resolved.notifications.overlayPosition = overlayPosition;
} else if (src.notifications.overlayPosition !== undefined) {
warn(
'notifications.overlayPosition',
src.notifications.overlayPosition,
resolved.notifications.overlayPosition,
'Expected top-left, top, or top-right.',
);
}
}
} }
+1 -10
View File
@@ -151,7 +151,6 @@ const SECTION_ORDER = new Map<string, number>(
'Startup warmups', 'Startup warmups',
'Logging', 'Logging',
'Updates', 'Updates',
'Notifications',
'Immersion tracking', 'Immersion tracking',
].map((section, index) => [section, index]), ].map((section, index) => [section, index]),
); );
@@ -412,9 +411,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) { ) {
return { category: 'behavior', section: 'Playback Behavior' }; return { category: 'behavior', section: 'Playback Behavior' };
} }
if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' };
}
if (path === 'mpv.aniskipButtonKey') { if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' }; return { category: 'input', section: 'Overlay Shortcuts' };
} }
@@ -482,7 +478,6 @@ function topSection(path: string): string {
mpv: 'mpv Playback', mpv: 'mpv Playback',
stats: 'Stats dashboard', stats: 'Stats dashboard',
startupWarmups: 'Startup warmups', startupWarmups: 'Startup warmups',
notifications: 'Notifications',
subsync: 'Subtitle Sync', subsync: 'Subtitle Sync',
texthooker: 'Texthooker', texthooker: 'Texthooker',
updates: 'Updates', updates: 'Updates',
@@ -582,7 +577,6 @@ function subsectionForPath(path: string): string | undefined {
if ( if (
leaf === 'toggleVisibleOverlayGlobal' || leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' || leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleNotificationHistory' ||
leaf === 'toggleSecondarySub' || leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' || leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched' leaf === 'markWatched'
@@ -692,7 +686,6 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'logging.level' || path === 'logging.level' ||
path === 'logging.rotation' || path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') || pathStartsWith(path, 'logging.files') ||
pathStartsWith(path, 'notifications') ||
path === 'youtube.primarySubLanguages' || path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') || pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync') pathStartsWith(path, 'subsync')
@@ -716,9 +709,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}), ...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
control: controlForPath(leaf.path, leaf.value), control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value, defaultValue: leaf.value,
...(option?.settingsEnumValues || option?.enumValues ...(option?.enumValues ? { enumValues: option.enumValues } : {}),
? { enumValues: option.settingsEnumValues ?? option.enumValues }
: {}),
restartBehavior: restartBehaviorForPath(leaf.path), restartBehavior: restartBehaviorForPath(leaf.path),
advanced: advanced:
leaf.path.startsWith('controller.') || leaf.path.startsWith('controller.') ||
-4
View File
@@ -10,7 +10,6 @@ import {
JimakuMediaInfo, JimakuMediaInfo,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuFieldGroupingRequestData, KikuFieldGroupingRequestData,
OverlayNotificationPayload,
} from '../../types'; } from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils'; import { sortJimakuFiles } from '../../jimaku/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc'; import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
@@ -41,7 +40,6 @@ export interface AnkiJimakuIpcRuntimeOptions {
setAnkiIntegration: (integration: AnkiIntegration | null) => void; setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -105,8 +103,6 @@ export function registerAnkiJimakuIpcRuntime(
options.createFieldGroupingCallback(), options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(), options.getKnownWordCacheStatePath(),
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
undefined,
options.showOverlayNotification,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);
+7 -71
View File
@@ -2,10 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup'; import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
function waitTurn(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) { function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
const calls: string[] = []; const calls: string[] = [];
const deps = { const deps = {
@@ -281,80 +277,20 @@ test('runAppReadyRuntime does not await background warmups', async () => {
releaseWarmup(); releaseWarmup();
}); });
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => { test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
const calls: string[] = []; const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({ const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false, startBackgroundWarmups: () => {
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true, calls.push('startBackgroundWarmups');
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
}, },
handleFirstRunSetup: async () => { loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
calls.push('handleFirstRunSetup'); createMpvClient: () => calls.push('createMpvClient'),
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
try {
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.equal(calls.includes('loadYomitanExtension:done'), false);
} finally {
releaseYomitan();
await readyPromise;
}
}); });
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
assert.equal(calls.includes('handleInitialArgs'), false);
releaseYomitan();
await readyPromise;
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
const { deps, calls } = makeDeps();
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups')); assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups')); assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
}); });
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
@@ -1676,6 +1676,276 @@ test('handleMediaChange splits matching parsed titles across distinct seasons',
} }
}); });
test('startup redistributes legacy combined anime rows across parsed seasons', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/Frieren S02E01.mkv',
'Frieren S02E01',
1,
1,
'/tmp/Frieren S02E01.mkv',
'Frieren S02E01.mkv',
'Frieren',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'season-repair-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'season-repair-session-2', 2, 3000, 4000, 2, 3000, 4000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(1, 2000, 1000, 1000, 1, 10, 1, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 2, 0, 0, 0, 0, 0, 0, 0);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren Season 1',
normalizedTitleKey: 'frieren season 1',
anilistId: 154587,
videoCount: 1,
totalActiveMs: 1000,
},
{
canonicalTitle: 'Frieren Season 2',
normalizedTitleKey: 'frieren season 2',
anilistId: null,
videoCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('startup skips single-season anime rows during legacy season repair', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'frieren',
'Frieren',
154587,
'Sousou no Frieren',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'local:/tmp/Frieren S01E01.mkv',
'Frieren S01E01',
1,
1,
'/tmp/Frieren S01E01.mkv',
'Frieren S01E01.mkv',
'Frieren',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
);
`);
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedApi = tracker as unknown as { db: DatabaseSync };
const rows = repairedApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.normalized_title_key AS normalizedTitleKey,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{
canonicalTitle: string;
normalizedTitleKey: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'Frieren',
normalizedTitleKey: 'frieren',
anilistId: 154587,
videoCount: 1,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin playback metadata links stream videos to existing series title', async () => { test('Jellyfin playback metadata links stream videos to existing series title', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -2847,6 +3117,216 @@ test('reassignAnimeAnilist preserves existing description when description is om
} }
}); });
test('reassignAnimeAnilist redistributes conflicting legacy combined row before assigning AniList id', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
anilist_id,
title_romaji,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'konosuba',
'KonoSuba',
21202,
'Kono Subarashii Sekai ni Shukufuku wo!',
1000,
1000
),
(
2,
'konosuba season 1',
'KonoSuba Season 1',
NULL,
NULL,
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
source_type,
source_path,
parsed_basename,
parsed_title,
parsed_season,
parsed_episode,
parser_source,
parser_confidence,
watched,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01',
1,
1,
'/tmp/KonoSuba S01E01.mkv',
'KonoSuba S01E01.mkv',
'KonoSuba',
1,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
2,
'local:/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01',
1,
1,
'/tmp/KonoSuba S02E01.mkv',
'KonoSuba S02E01.mkv',
'KonoSuba',
2,
1,
'fallback',
0.9,
1,
0,
1000,
1000
),
(
3,
'local:/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02',
2,
1,
'/tmp/KonoSuba S01E02.mkv',
'KonoSuba S01E02.mkv',
'KonoSuba',
1,
2,
'fallback',
0.9,
1,
0,
1000,
1000
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
ended_at_ms,
status,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 'anilist-conflict-session-1', 1, 1000, 2000, 2, 1000, 2000),
(2, 'anilist-conflict-session-2', 2, 3000, 4000, 2, 3000, 4000),
(3, 'anilist-conflict-session-3', 3, 5000, 6000, 2, 5000, 6000);
INSERT INTO imm_subtitle_lines (
session_id,
video_id,
anime_id,
line_index,
text,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(1, 1, 1, 0, 'season one legacy line', 1000, 1000),
(2, 2, 1, 0, 'season two legacy line', 1000, 1000);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES
(1, 2000, 1000, 1000, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0),
(2, 4000, 2000, 2000, 2, 20, 0, 0, 0, 0, 0, 0, 0, 0),
(3, 6000, 3000, 3000, 3, 30, 0, 0, 0, 0, 0, 0, 0, 0);
`);
await tracker.reassignAnimeAnilist(2, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
});
const rows = privateApi.db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(DISTINCT v.video_id) AS videoCount,
COUNT(DISTINCT sl.line_id) AS subtitleLineCount,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
LEFT JOIN imm_subtitle_lines sl ON sl.anime_id = a.anime_id
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
subtitleLineCount: number;
totalActiveMs: number;
}>;
assert.deepEqual(rows, [
{
canonicalTitle: 'KonoSuba Season 1',
anilistId: 21202,
videoCount: 2,
subtitleLineCount: 1,
totalActiveMs: 4000,
},
{
canonicalTitle: 'KonoSuba Season 2',
anilistId: null,
videoCount: 1,
subtitleLineCount: 1,
totalActiveMs: 2000,
},
]);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => { test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
@@ -91,6 +91,10 @@ import {
upsertCoverArt, upsertCoverArt,
} from './immersion-tracker/query-maintenance'; } from './immersion-tracker/query-maintenance';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair'; import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
} from './immersion-tracker/anime-season-repair';
import { import {
buildVideoKey, buildVideoKey,
deriveCanonicalTitle, deriveCanonicalTitle,
@@ -475,6 +479,13 @@ export class ImmersionTrackerService {
`Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`, `Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`,
); );
} }
const seasonRepair = repairLegacySeasonlessAnimeRows(this.db);
if (seasonRepair.movedVideos > 0 || seasonRepair.deletedAnimeRows > 0) {
this.logger.info(
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
);
rebuildLifetimeSummaryTables(this.db);
}
if (shouldBackfillLifetimeSummaries(this.db)) { if (shouldBackfillLifetimeSummaries(this.db)) {
const result = rebuildLifetimeSummaryTables(this.db); const result = rebuildLifetimeSummaryTables(this.db);
if (result.appliedSessions > 0) { if (result.appliedSessions > 0) {
@@ -733,6 +744,7 @@ export class ImmersionTrackerService {
coverUrl?: string | null; coverUrl?: string | null;
}, },
): Promise<void> { ): Promise<void> {
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
this.db this.db
.prepare( .prepare(
` `
@@ -758,6 +770,9 @@ export class ImmersionTrackerService {
nowMs(), nowMs(),
animeId, animeId,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaryTables(this.db);
}
// Update cover art for all videos in this anime // Update cover art for all videos in this anime
if (info.coverUrl) { if (info.coverUrl) {
@@ -680,6 +680,116 @@ test('split maintenance helpers update anime metadata and watched state', () =>
} }
}); });
test('updateAnimeAnilistInfo redistributes legacy combined row before assigning duplicate AniList id', () => {
const { db, dbPath } = createDb();
try {
const legacyAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const seasonAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'KonoSuba',
canonicalTitle: 'KonoSuba',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const legacySeasonOneVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e01.mkv', {
canonicalTitle: 'KonoSuba S01E01',
sourcePath: '/tmp/konosuba-s01e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const legacySeasonTwoVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s02e01.mkv', {
canonicalTitle: 'KonoSuba S02E01',
sourcePath: '/tmp/konosuba-s02e01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e02.mkv', {
canonicalTitle: 'KonoSuba S01E02',
sourcePath: '/tmp/konosuba-s01e02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, legacySeasonOneVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s01e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, legacySeasonTwoVideoId, {
animeId: legacyAnimeId,
parsedBasename: 'konosuba-s02e01.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 2,
parsedEpisode: 1,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
linkVideoToAnimeRecord(db, targetVideoId, {
animeId: seasonAnimeId,
parsedBasename: 'konosuba-s01e02.mkv',
parsedTitle: 'KonoSuba',
parsedSeason: 1,
parsedEpisode: 2,
parserSource: 'test',
parserConfidence: 1,
parseMetadataJson: null,
});
updateAnimeAnilistInfo(db, targetVideoId, {
anilistId: 21202,
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
titleEnglish: null,
titleNative: null,
episodesTotal: 10,
});
const rows = db
.prepare(
`
SELECT
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
COUNT(v.video_id) AS videoCount
FROM imm_anime a
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
GROUP BY a.anime_id
ORDER BY a.canonical_title ASC
`,
)
.all() as Array<{
canonicalTitle: string;
anilistId: number | null;
videoCount: number;
}>;
assert.deepEqual(rows, [
{ canonicalTitle: 'KonoSuba Season 1', anilistId: 21202, videoCount: 2 },
{ canonicalTitle: 'KonoSuba Season 2', anilistId: null, videoCount: 1 },
]);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('deleteSessions refreshes only rollups affected by deleted sessions', () => { test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
const { db, dbPath } = createDb(); const { db, dbPath } = createDb();
@@ -0,0 +1,330 @@
import type { DatabaseSync } from './sqlite';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface AnimeSeasonRepairSummary {
scanned: number;
repaired: number;
movedVideos: number;
deletedAnimeRows: number;
}
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
interface ParsedVideoRow {
video_id: number;
parsed_title: string | null;
parsed_season: number | null;
}
interface RedistributeOptions {
transferAnilistToAnimeId?: number | null;
transferLegacyAnilist?: boolean;
overwriteTargetAnilist?: boolean;
}
function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
return {
scanned,
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
};
}
function mergeSummary(
target: AnimeSeasonRepairSummary,
source: AnimeSeasonRepairSummary,
): AnimeSeasonRepairSummary {
target.scanned += source.scanned;
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
return target;
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function normalizeSeason(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
return null;
}
return value;
}
function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
return db
.prepare(
`
SELECT
anime_id,
anilist_id,
title_romaji,
title_english,
title_native,
episodes_total,
description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) as AnimeRow | null;
}
function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
return db
.prepare(
`
SELECT video_id, parsed_title, parsed_season
FROM imm_videos
WHERE anime_id = ?
ORDER BY video_id ASC
`,
)
.all(animeId) as ParsedVideoRow[];
}
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
const row = db
.prepare(
`
SELECT 1 AS found
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.get(animeId, animeId) as { found: number } | null;
return Boolean(row);
}
function assignAnilistToTarget(
db: DatabaseSync,
source: AnimeRow,
targetAnimeId: number,
overwriteTarget: boolean,
updatedAt: string,
): boolean {
if (source.anilist_id === null || targetAnimeId === source.anime_id) {
return false;
}
const target = getAnimeRow(db, targetAnimeId);
if (!target) {
return false;
}
if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) {
return false;
}
db.prepare(
`
UPDATE imm_anime
SET anilist_id = NULL,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
).run(updatedAt, source.anime_id);
const updated = db
.prepare(
`
UPDATE imm_anime
SET
anilist_id = ?,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
episodes_total = COALESCE(?, episodes_total),
description = COALESCE(?, description),
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(
source.anilist_id,
source.title_romaji,
source.title_english,
source.title_native,
source.episodes_total,
source.description,
updatedAt,
targetAnimeId,
) as { changes: number };
return updated.changes > 0;
}
function redistributeAnimeRowByParsedSeasonsInTransaction(
db: DatabaseSync,
animeId: number,
options: RedistributeOptions = {},
): AnimeSeasonRepairSummary {
const source = getAnimeRow(db, animeId);
if (!source) {
return emptySummary(1);
}
const videos = getParsedVideos(db, animeId);
const summary = emptySummary(1);
const updatedAt = toDbTimestamp(nowMs());
const targetBySeason = new Map<number, number>();
for (const video of videos) {
const parsedTitle = video.parsed_title?.trim();
const season = normalizeSeason(video.parsed_season);
if (!parsedTitle || season === null) {
continue;
}
const targetAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle,
canonicalTitle: parsedTitle,
seasonScope: season,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
targetBySeason.set(season, targetAnimeId);
if (targetAnimeId === animeId) {
continue;
}
const videoUpdate = db
.prepare(
`
UPDATE imm_videos
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
const lineUpdate = db
.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?,
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
)
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
if (videoUpdate.changes > 0 || lineUpdate.changes > 0) {
summary.movedVideos += 1;
}
}
const transferTarget =
options.transferAnilistToAnimeId ??
(options.transferLegacyAnilist
? (targetBySeason.get(1) ??
(targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null))
: null);
if (transferTarget) {
const transferred = assignAnilistToTarget(
db,
source,
transferTarget,
options.overwriteTargetAnilist ?? false,
updatedAt,
);
if (transferred) {
summary.repaired += 1;
}
}
if (!hasAnimeReferences(db, animeId)) {
const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as {
changes: number;
};
if (deleted.changes > 0) {
summary.deletedAnimeRows += 1;
}
}
if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) {
summary.repaired += 1;
}
return summary;
}
export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary {
return runInTransaction(db, () => {
const candidates = db
.prepare(
`
SELECT a.anime_id AS animeId
FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0
GROUP BY a.anime_id
HAVING COUNT(DISTINCT v.parsed_season) > 1
ORDER BY a.anime_id ASC
`,
)
.all() as Array<{ animeId: number }>;
const summary = emptySummary();
for (const candidate of candidates) {
mergeSummary(
summary,
redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, {
transferLegacyAnilist: true,
}),
);
}
return summary;
});
}
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
`
SELECT anime_id AS animeId
FROM imm_anime
WHERE anilist_id = ?
AND anime_id != ?
LIMIT 1
`,
)
.get(anilistId, targetAnimeId) as { animeId: number } | null;
if (!conflict) {
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
}),
);
}
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage'; import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummariesInTransaction } from './lifetime'; import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance'; import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time'; import { nowMs } from './time';
import { resolveAnimeAnilistConflict } from './anime-season-repair';
import { PartOfSpeech, type MergedToken } from '../../../types'; import { PartOfSpeech, type MergedToken } from '../../../types';
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage'; import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
@@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo(
} | null; } | null;
if (!row?.anime_id) return; if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
anime_id: number | null;
} | null;
if (!targetRow?.anime_id) return;
db.prepare( db.prepare(
` `
UPDATE imm_anime UPDATE imm_anime
@@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo(
info.titleNative, info.titleNative,
info.episodesTotal, info.episodesTotal,
toDbTimestamp(nowMs()), toDbTimestamp(nowMs()),
row.anime_id, targetRow.anime_id,
); );
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaries(db);
}
} }
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
+13 -29
View File
@@ -6,7 +6,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
const calls: string[] = []; const calls: string[] = [];
const sentCommands: (string | number)[][] = []; const sentCommands: (string | number)[][] = [];
const osd: string[] = []; const osd: string[] = [];
const playbackFeedback: string[] = [];
const options: Parameters<typeof handleMpvCommandFromIpc>[1] = { const options: Parameters<typeof handleMpvCommandFromIpc>[1] = {
specialCommands: { specialCommands: {
SUBSYNC_TRIGGER: '__subsync-trigger', SUBSYNC_TRIGGER: '__subsync-trigger',
@@ -39,9 +38,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
}, },
showPlaybackFeedback: (text) => {
playbackFeedback.push(text);
},
mpvReplaySubtitle: () => { mpvReplaySubtitle: () => {
calls.push('replay'); calls.push('replay');
}, },
@@ -59,7 +55,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
hasRuntimeOptionsManager: () => true, hasRuntimeOptionsManager: () => true,
...overrides, ...overrides,
}; };
return { options, calls, sentCommands, osd, playbackFeedback }; return { options, calls, sentCommands, osd };
} }
test('handleMpvCommandFromIpc forwards regular mpv commands', () => { test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
@@ -69,53 +65,41 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
test('handleMpvCommandFromIpc routes show-text through playback feedback', () => { test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions(); const { options, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['show-text', 'Primary subtitle: hover', '1500'], options);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
assert.deepEqual(playbackFeedback, ['Primary subtitle: hover']);
});
test('handleMpvCommandFromIpc emits feedback for subtitle position keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options); handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]); assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']);
assert.deepEqual(playbackFeedback, ['Subtitle position: ${sub-pos}']);
}); });
test('handleMpvCommandFromIpc emits resolved feedback for primary subtitle track keybinding proxies', async () => { test('handleMpvCommandFromIpc emits resolved osd for primary subtitle track keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions({ const { options, sentCommands, osd } = createOptions({
resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)', resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)',
}); });
handleMpvCommandFromIpc(['cycle', 'sid'], options); handleMpvCommandFromIpc(['cycle', 'sid'], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['cycle', 'sid']]); assert.deepEqual(sentCommands, [['cycle', 'sid']]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Subtitle track: Internal #3 - Japanese (active)']);
assert.deepEqual(playbackFeedback, ['Subtitle track: Internal #3 - Japanese (active)']);
}); });
test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle track keybinding proxies', async () => { test('handleMpvCommandFromIpc emits resolved osd for secondary subtitle track keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions({ const { options, sentCommands, osd } = createOptions({
resolveProxyCommandOsd: async () => resolveProxyCommandOsd: async () =>
'Secondary subtitle track: External #8 - English Commentary', 'Secondary subtitle track: External #8 - English Commentary',
}); });
handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options); handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]); assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Secondary subtitle track: External #8 - English Commentary']);
assert.deepEqual(playbackFeedback, ['Secondary subtitle track: External #8 - English Commentary']);
}); });
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => { test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions(); const { options, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]); assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
assert.deepEqual(osd, []); assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
}); });
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => { test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
+2 -13
View File
@@ -25,7 +25,6 @@ export interface HandleMpvCommandFromIpcOptions {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void; mpvPlayNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>; shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
@@ -69,14 +68,13 @@ function showResolvedProxyCommandOsd(
): void { ): void {
const template = resolveProxyCommandOsdTemplate(command); const template = resolveProxyCommandOsdTemplate(command);
if (!template) return; if (!template) return;
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
const emit = async () => { const emit = async () => {
try { try {
const resolved = await options.resolveProxyCommandOsd?.(command); const resolved = await options.resolveProxyCommandOsd?.(command);
showFeedback(resolved || template); options.showMpvOsd(resolved || template);
} catch { } catch {
showFeedback(template); options.showMpvOsd(template);
} }
}; };
@@ -144,15 +142,6 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (first === 'show-text') {
const message = (typeof command[1] === 'string' ? command[1] : String(command[1] ?? '')).trim();
if (message) {
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
showFeedback(message);
}
return;
}
if (options.isMpvConnected()) { if (options.isMpvConnected()) {
if (first === options.specialCommands.REPLAY_SUBTITLE) { if (first === options.specialCommands.REPLAY_SUBTITLE) {
options.mpvReplaySubtitle(); options.mpvReplaySubtitle();
-44
View File
@@ -137,7 +137,6 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {}, saveControllerConfig: async () => {},
saveControllerPreference: async () => {}, saveControllerPreference: async () => {},
@@ -243,7 +242,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {}, saveControllerConfig: () => {},
saveControllerPreference: () => {}, saveControllerPreference: () => {},
@@ -554,7 +552,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {}, saveControllerConfig: () => {},
saveControllerPreference: () => {}, saveControllerPreference: () => {},
@@ -980,7 +977,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {}, saveControllerConfig: () => {},
saveControllerPreference: (update) => { saveControllerPreference: (update) => {
@@ -1062,7 +1058,6 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {}, saveControllerConfig: async () => {},
saveControllerPreference: async (update) => { saveControllerPreference: async (update) => {
@@ -1267,44 +1262,6 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
]); ]);
}); });
test('registerIpcHandlers forwards valid overlay notification actions', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
registerIpcHandlers(
createRegisterIpcDeps({
handleOverlayNotificationAction: ((
notificationId: string,
actionId: string,
noteId?: number,
) => {
actions.push({ notificationId, actionId, noteId });
}) as IpcServiceDeps['handleOverlayNotificationAction'],
} as Partial<IpcServiceDeps>),
registrar,
);
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
assert.ok(actionHandler);
actionHandler({}, null);
actionHandler({}, { notificationId: '', actionId: 'install-update' });
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
actionHandler(
{},
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 },
);
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
actionHandler(
{},
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
);
assert.deepEqual(actions, [
{ notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined },
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => { test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers( registerIpcHandlers(
@@ -1332,7 +1289,6 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(), getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {}, saveControllerConfig: async () => {},
saveControllerPreference: async () => {}, saveControllerPreference: async () => {},
-53
View File
@@ -53,11 +53,6 @@ export interface IpcServiceDeps {
interactive: boolean, interactive: boolean,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
) => void; ) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
noteId?: number,
) => void | Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
@@ -85,7 +80,6 @@ export interface IpcServiceDeps {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -229,25 +223,6 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
return parsed; return parsed;
} }
function parseOverlayNotificationActionPayload(
payload: unknown,
): { notificationId: string; actionId: string; noteId?: number } | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
const notificationId = record.notificationId;
const actionId = record.actionId;
const noteId = record.noteId;
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
if (
noteId !== undefined &&
(typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0)
) {
return null;
}
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
}
export interface IpcDepsRuntimeOptions { export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
@@ -267,11 +242,6 @@ export interface IpcDepsRuntimeOptions {
interactive: boolean, interactive: boolean,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
) => void; ) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
noteId?: number,
) => void | Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
@@ -292,7 +262,6 @@ export interface IpcDepsRuntimeOptions {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>; dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string; getStatsToggleKey: () => string;
getMarkWatchedKey: () => string; getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig; getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -343,7 +312,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalOpened: options.onOverlayModalOpened, onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint, onOverlayInteractiveHint: options.onOverlayInteractiveHint,
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
openYomitanSettings: options.openYomitanSettings, openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext, recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp, quitApp: options.quitApp,
@@ -381,7 +349,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey, getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey, getMarkWatchedKey: options.getMarkWatchedKey,
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
getControllerConfig: options.getControllerConfig, getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig, saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference, saveControllerPreference: options.saveControllerPreference,
@@ -506,22 +473,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow); deps.onOverlayModalOpened(parsedModal, senderWindow);
}); });
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
const parsedPayload = parseOverlayNotificationActionPayload(payload);
if (!parsedPayload) return;
void Promise.resolve(
deps.handleOverlayNotificationAction?.(
parsedPayload.notificationId,
parsedPayload.actionId,
parsedPayload.noteId,
),
).catch((error) => {
console.warn(
'Failed to handle overlay notification action:',
error instanceof Error ? error.message : String(error),
);
});
});
ipc.handle( ipc.handle(
IPC_CHANNELS.request.youtubePickerResolve, IPC_CHANNELS.request.youtubePickerResolve,
@@ -690,10 +641,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getMarkWatchedKey(); return deps.getMarkWatchedKey();
}); });
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
return deps.getOverlayNotificationPosition();
});
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => { ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
return deps.getControllerConfig(); return deps.getControllerConfig();
}); });
@@ -6,7 +6,6 @@ import {
AnkiConnectConfig, AnkiConnectConfig,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuFieldGroupingRequestData, KikuFieldGroupingRequestData,
OverlayNotificationPayload,
WindowGeometry, WindowGeometry,
} from '../../types'; } from '../../types';
@@ -20,7 +19,6 @@ type CreateAnkiIntegrationArgs = {
subtitleTimingTracker: unknown; subtitleTimingTracker: unknown;
mpvClient: { send?: (payload: { command: string[] }) => void }; mpvClient: { send?: (payload: { command: string[] }) => void };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -63,8 +61,6 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.createFieldGroupingCallback(), args.createFieldGroupingCallback(),
args.knownWordCacheStatePath, args.knownWordCacheStatePath,
args.aiConfig, args.aiConfig,
undefined,
args.showOverlayNotification,
); );
} }
@@ -127,7 +123,6 @@ export function initializeOverlayRuntime(
getAnkiIntegration?: () => unknown | null; getAnkiIntegration?: () => unknown | null;
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -161,7 +156,6 @@ export function initializeOverlayAnkiIntegration(options: {
getAnkiIntegration?: () => unknown | null; getAnkiIntegration?: () => unknown | null;
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -197,7 +191,6 @@ export function initializeOverlayAnkiIntegration(options: {
subtitleTimingTracker, subtitleTimingTracker,
mpvClient, mpvClient,
showDesktopNotification: options.showDesktopNotification, showDesktopNotification: options.showDesktopNotification,
showOverlayNotification: options.showOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback, createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(), knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
}); });
@@ -32,7 +32,6 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
toggleSubtitleSidebar: null, toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides, ...overrides,
}; };
} }
@@ -27,7 +27,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
toggleSubtitleSidebar: null, toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides, ...overrides,
}; };
} }
+2 -122
View File
@@ -154,127 +154,7 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
test('macOS dismisses overlay loading OSD when tracker recovers', () => { test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
const osdMessages: string[] = [];
const dismissedOsds: string[] = [];
let tracking = false;
let geometry: WindowTrackerStub['getGeometry'] extends () => infer T ? T : never = null;
const tracker: WindowTrackerStub = {
isTracking: () => tracking,
getGeometry: () => geometry,
isTargetWindowFocused: () => tracking,
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: trackerWarning,
setTrackerNotReadyWarningShown: (shown: boolean) => {
trackerWarning = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
dismissOverlayLoadingOsd: () => {
dismissedOsds.push('dismiss');
},
} as never);
run();
tracking = true;
geometry = { x: 0, y: 0, width: 1280, height: 720 };
run();
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.deepEqual(dismissedOsds, ['dismiss']);
assert.equal(trackerWarning, false);
assert.ok(calls.includes('show-inactive'));
});
test('tracked non-native overlay shows loading OSD until renderer content is visible', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
let loadingShown = false;
const osdMessages: string[] = [];
const dismissedOsds: string[] = [];
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: loadingShown,
setTrackerNotReadyWarningShown: (shown: boolean) => {
loadingShown = shown;
},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
showOverlayLoadingOsd: (message: string) => {
osdMessages.push(message);
},
dismissOverlayLoadingOsd: () => {
dismissedOsds.push('dismiss');
},
} as never);
setContentReady(false);
run();
run();
assert.equal(loadingShown, true);
assert.deepEqual(osdMessages, ['Overlay loading...']);
assert.deepEqual(dismissedOsds, []);
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('show-inactive'));
setContentReady(true);
run();
assert.equal(loadingShown, false);
assert.deepEqual(dismissedOsds, ['dismiss']);
assert.ok(calls.includes('show-inactive'));
});
test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
let trackerWarning = false; let trackerWarning = false;
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
@@ -317,7 +197,7 @@ test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker
assert.ok(!calls.includes('update-bounds')); assert.ok(!calls.includes('update-bounds'));
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
assert.ok(calls.includes('osd')); assert.ok(!calls.includes('osd'));
}); });
test('non-native passive overlay stays click-through after subsequent visibility updates', () => { test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
+1 -25
View File
@@ -88,7 +88,6 @@ export function updateVisibleOverlayVisibility(args: {
isMacOSPlatform?: boolean; isMacOSPlatform?: boolean;
isWindowsPlatform?: boolean; isWindowsPlatform?: boolean;
showOverlayLoadingOsd?: (message: string) => void; showOverlayLoadingOsd?: (message: string) => void;
dismissOverlayLoadingOsd?: () => void;
shouldShowOverlayLoadingOsd?: () => boolean; shouldShowOverlayLoadingOsd?: () => boolean;
markOverlayLoadingOsdShown?: () => void; markOverlayLoadingOsdShown?: () => void;
resetOverlayLoadingOsdSuppression?: () => void; resetOverlayLoadingOsdSuppression?: () => void;
@@ -311,18 +310,8 @@ export function updateVisibleOverlayVisibility(args: {
!args.isWindowsPlatform && !args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true); (!args.forceMousePassthrough || args.isMacOSPlatform === true);
const isWaitingForOverlayContentReady = (): boolean => {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
return (
!mainWindow.isVisible() &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
);
};
const maybeShowOverlayLoadingOsd = (): void => { const maybeShowOverlayLoadingOsd = (): void => {
if (!args.showOverlayLoadingOsd) { if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return; return;
} }
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) { if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
@@ -331,9 +320,6 @@ export function updateVisibleOverlayVisibility(args: {
args.showOverlayLoadingOsd('Overlay loading...'); args.showOverlayLoadingOsd('Overlay loading...');
args.markOverlayLoadingOsdShown?.(); args.markOverlayLoadingOsdShown?.();
}; };
const maybeDismissOverlayLoadingOsd = (): void => {
args.dismissOverlayLoadingOsd?.();
};
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => { const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
if ( if (
@@ -364,7 +350,6 @@ export function updateVisibleOverlayVisibility(args: {
if (!args.visibleOverlayVisible) { if (!args.visibleOverlayVisible) {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
args.resetOverlayLoadingOsdSuppression?.(); args.resetOverlayLoadingOsdSuppression?.();
maybeDismissOverlayLoadingOsd();
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
clearPendingWindowsOverlayReveal(mainWindow); clearPendingWindowsOverlayReveal(mainWindow);
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
@@ -386,15 +371,7 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
return; return;
} }
if (isWaitingForOverlayContentReady()) {
if (!args.trackerNotReadyWarningShown) {
args.setTrackerNotReadyWarningShown(true);
maybeShowOverlayLoadingOsd();
}
} else {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
maybeDismissOverlayLoadingOsd();
}
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateVisibleOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
@@ -455,7 +432,6 @@ export function updateVisibleOverlayVisibility(args: {
(mainWindow.isVisible() || hasRetainedTrackedGeometry) (mainWindow.isVisible() || hasRetainedTrackedGeometry)
) { ) {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
maybeDismissOverlayLoadingOsd();
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateVisibleOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
-2
View File
@@ -116,7 +116,6 @@ export function createOverlayWindow(
linuxX11FullscreenOverlay?: boolean; linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void; onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void; onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void; onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void; onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null; yomitanSession?: Session | null;
@@ -140,7 +139,6 @@ export function createOverlayWindow(
window.webContents.on('did-finish-load', () => { window.webContents.on('did-finish-load', () => {
window.setTitle(OVERLAY_WINDOW_TITLES[kind]); window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
options.onRuntimeOptionsChanged(); options.onRuntimeOptionsChanged();
options.onWindowDidFinishLoad?.();
}); });
window.webContents.on('page-title-updated', (event) => { window.webContents.on('page-title-updated', (event) => {
@@ -25,7 +25,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
mineSentenceCount: (count) => calls.push(`mine:${count}`), mineSentenceCount: (count) => calls.push(`mine:${count}`),
toggleSecondarySub: () => calls.push('secondary'), toggleSecondarySub: () => calls.push('secondary'),
toggleSubtitleSidebar: () => calls.push('sidebar'), toggleSubtitleSidebar: () => calls.push('sidebar'),
toggleNotificationHistory: () => calls.push('notification-history'),
markLastCardAsAudioCard: async () => { markLastCardAsAudioCard: async () => {
calls.push('audio'); calls.push('audio');
}, },
-4
View File
@@ -14,7 +14,6 @@ export interface SessionActionExecutorDeps {
mineSentenceCount: (count: number) => void; mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void; toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void; toggleSubtitleSidebar: () => void;
toggleNotificationHistory: () => void;
markLastCardAsAudioCard: () => Promise<void>; markLastCardAsAudioCard: () => Promise<void>;
markActiveVideoWatched: () => Promise<boolean>; markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -80,9 +79,6 @@ export async function dispatchSessionAction(
case 'toggleSubtitleSidebar': case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar(); deps.toggleSubtitleSidebar();
return; return;
case 'toggleNotificationHistory':
deps.toggleNotificationHistory();
return;
case 'markAudioCard': case 'markAudioCard':
await deps.markLastCardAsAudioCard(); await deps.markLastCardAsAudioCard();
return; return;
+1 -5
View File
@@ -26,7 +26,6 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
toggleSubtitleSidebar: null, toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides, ...overrides,
}; };
} }
@@ -196,10 +195,7 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
platform: 'win32', platform: 'win32',
}); });
assert.deepEqual( assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
result.bindings.map((binding) => binding.sourcePath),
['keybindings[0].key'],
);
assert.deepEqual( assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`), result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku'], ['unsupported:shortcuts.openJimaku'],
-1
View File
@@ -59,7 +59,6 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openControllerSelect', actionId: 'openControllerSelect' }, { key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' }, { key: 'openControllerDebug', actionId: 'openControllerDebug' },
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' }, { key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
]; ];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] { function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
+3 -4
View File
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
]); ]);
}); });
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => { test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
const calls: string[] = []; const calls: string[] = [];
await runAppReadyRuntime({ await runAppReadyRuntime({
@@ -354,10 +354,9 @@ test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yom
shouldSkipHeavyStartup: () => false, shouldSkipHeavyStartup: () => false,
}); });
assert.ok(calls.indexOf('load-yomitan') !== -1);
assert.ok(calls.indexOf('init-overlay') !== -1); assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('warmups') !== -1); assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
assert.equal(calls.includes('load-yomitan'), false);
}); });
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => { test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
+8 -40
View File
@@ -158,7 +158,6 @@ export interface AppReadyRuntimeDeps {
shouldRunHeadlessInitialCommand?: () => boolean; shouldRunHeadlessInitialCommand?: () => boolean;
shouldUseMinimalStartup?: () => boolean; shouldUseMinimalStartup?: () => boolean;
shouldSkipHeavyStartup?: () => boolean; shouldSkipHeavyStartup?: () => boolean;
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
} }
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
@@ -230,31 +229,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now(); const startupStartedAtMs = now();
const ensureYomitanExtensionReady = const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension; deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
let firstRunSetupHandled = false;
let initialArgsHandled = false;
let backgroundWarmupsHandled = false;
const handleFirstRunSetupOnce = async (): Promise<void> => {
if (firstRunSetupHandled) {
return;
}
firstRunSetupHandled = true;
await deps.handleFirstRunSetup();
};
const handleInitialArgsOnce = (): void => {
if (initialArgsHandled) {
return;
}
initialArgsHandled = true;
deps.handleInitialArgs();
};
const startBackgroundWarmupsOnce = (): void => {
if (backgroundWarmupsHandled) {
return;
}
backgroundWarmupsHandled = true;
deps.startBackgroundWarmups();
};
deps.ensureDefaultConfigBootstrap(); deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) { if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig(); deps.reloadConfig();
@@ -273,7 +247,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldUseMinimalStartup?.()) { if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig(); deps.reloadConfig();
handleInitialArgsOnce(); deps.handleInitialArgs();
return; return;
} }
@@ -282,8 +256,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) { if (deps.shouldSkipHeavyStartup?.()) {
await ensureYomitanExtensionReady(); await ensureYomitanExtensionReady();
deps.reloadConfig(); deps.reloadConfig();
await handleFirstRunSetupOnce(); await deps.handleFirstRunSetup();
handleInitialArgsOnce(); deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`); deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
return; return;
} }
@@ -305,6 +279,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
for (const warning of deps.getConfigWarnings()) { for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning); deps.logConfigWarning(warning);
} }
deps.startBackgroundWarmups();
deps.loadSubtitlePosition(); deps.loadSubtitlePosition();
deps.resolveKeybindings(); deps.resolveKeybindings();
deps.createMpvClient(); deps.createMpvClient();
@@ -350,24 +326,16 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.'); deps.log('Texthooker-only mode enabled; skipping overlay window.');
startBackgroundWarmupsOnce();
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true); deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
startBackgroundWarmupsOnce();
} else { } else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.'); deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
startBackgroundWarmupsOnce();
} else {
startBackgroundWarmupsOnce();
await ensureYomitanExtensionReady(); await ensureYomitanExtensionReady();
} }
}
await handleFirstRunSetupOnce(); await deps.handleFirstRunSetup();
handleInitialArgsOnce(); deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`); deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
} }
-2
View File
@@ -19,7 +19,6 @@ export interface ConfiguredShortcuts {
openControllerSelect: string | null | undefined; openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined; openControllerDebug: string | null | undefined;
toggleSubtitleSidebar: string | null | undefined; toggleSubtitleSidebar: string | null | undefined;
toggleNotificationHistory: string | null | undefined;
} }
export function resolveConfiguredShortcuts( export function resolveConfiguredShortcuts(
@@ -68,6 +67,5 @@ export function resolveConfiguredShortcuts(
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')), openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')), openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')), toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
}; };
} }
+1 -7
View File
@@ -16,10 +16,7 @@ export interface ConfiguredWindowsMpvLaunch {
} }
export function buildWindowsMpvPluginRuntimeConfig( export function buildWindowsMpvPluginRuntimeConfig(
config: Pick< config: Pick<ResolvedConfig, 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'>,
ResolvedConfig,
'ankiConnect' | 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'
>,
): SubminerPluginRuntimeScriptOptConfig { ): SubminerPluginRuntimeScriptOptConfig {
return { return {
socketPath: config.mpv.socketPath, socketPath: config.mpv.socketPath,
@@ -30,9 +27,6 @@ export function buildWindowsMpvPluginRuntimeConfig(
autoStart: config.mpv.autoStartSubMiner, autoStart: config.mpv.autoStartSubMiner,
autoStartVisibleOverlay: config.auto_start_overlay, autoStartVisibleOverlay: config.auto_start_overlay,
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
osdMessages:
config.ankiConnect.behavior.notificationType === 'osd' ||
config.ankiConnect.behavior.notificationType === 'osd-system',
texthookerEnabled: config.texthooker.launchAtStartup, texthookerEnabled: config.texthooker.launchAtStartup,
aniskipEnabled: config.mpv.aniskipEnabled, aniskipEnabled: config.mpv.aniskipEnabled,
aniskipButtonKey: config.mpv.aniskipButtonKey, aniskipButtonKey: config.mpv.aniskipButtonKey,
-2
View File
@@ -325,7 +325,6 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner, autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay, autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady, autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
osdMessages: false,
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup, texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled, aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey, aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
@@ -382,7 +381,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
autoStart: false, autoStart: false,
autoStartVisibleOverlay: false, autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false, autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: true, texthookerEnabled: true,
aniskipEnabled: false, aniskipEnabled: false,
aniskipButtonKey: 'F8', aniskipButtonKey: 'F8',
+50 -426
View File
@@ -62,7 +62,6 @@ import {
type ForegroundSuppressionGraceState, type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction, mapOverlayMeasurementForPointerInteraction,
resolveForegroundSuppressionWithGrace, resolveForegroundSuppressionWithGrace,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction, tickLinuxOverlayPointerInteraction,
} from './main/runtime/linux-overlay-pointer-interaction'; } from './main/runtime/linux-overlay-pointer-interaction';
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point'; import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
@@ -139,13 +138,9 @@ import type {
SubtitleData, SubtitleData,
SubtitleMiningContext, SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
OverlayNotificationPayload,
OverlayNotificationEventPayload,
NotificationType,
UpdateChannel, UpdateChannel,
WindowGeometry, WindowGeometry,
} from './types'; } from './types';
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
import { AnkiIntegration } from './anki-integration'; import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options'; import { RuntimeOptionsManager } from './runtime-options';
@@ -192,7 +187,6 @@ import {
import { AnkiConnectClient } from './anki-connect'; import { AnkiConnectClient } from './anki-connect';
import { import {
getStartupModeFlags, getStartupModeFlags,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload, shouldRefreshAnilistOnConfigReload,
shouldStartAutomaticUpdateChecks, shouldStartAutomaticUpdateChecks,
} from './main/runtime/startup-mode-flags'; } from './main/runtime/startup-mode-flags';
@@ -605,21 +599,7 @@ import {
} from './main/runtime/update/release-assets'; } from './main/runtime/update/release-assets';
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy'; import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
INSTALL_UPDATE_ACTION_ID,
notifyUpdateAvailable,
UPDATE_AVAILABLE_NOTIFICATION_ID,
} from './main/runtime/update/update-notifications';
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
type ConfiguredStatusNotificationOptions,
} from './main/runtime/configured-status-notification';
import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing';
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
import { import {
runUpdateCliCommand, runUpdateCliCommand,
@@ -1252,7 +1232,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
mainWindow.webContents.focus(); mainWindow.webContents.focus();
} }
}, },
showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text), showMpvOsd: (text: string) => showMpvOsd(text),
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
notifyPrimarySubtitleLoaded: () => notifyPrimarySubtitleLoaded: () =>
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(), youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
@@ -1315,6 +1295,7 @@ const autoplayReadyGate = createAutoplayReadyGate({
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
}, },
isSignalTargetReady: (signal) => isSignalTargetReady: (signal) =>
isTokenizationWarmupReady() &&
isVisibleOverlayAutoplayTargetReady( isVisibleOverlayAutoplayTargetReady(
{ {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
@@ -1486,9 +1467,6 @@ function getMpvPluginRuntimeConfig() {
autoStart: config.mpv.autoStartSubMiner, autoStart: config.mpv.autoStartSubMiner,
autoStartVisibleOverlay: config.auto_start_overlay, autoStartVisibleOverlay: config.auto_start_overlay,
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
osdMessages:
config.ankiConnect.behavior.notificationType === 'osd' ||
config.ankiConnect.behavior.notificationType === 'osd-system',
texthookerEnabled: config.texthooker.launchAtStartup, texthookerEnabled: config.texthooker.launchAtStartup,
aniskipEnabled: config.mpv.aniskipEnabled, aniskipEnabled: config.mpv.aniskipEnabled,
aniskipButtonKey: config.mpv.aniskipButtonKey, aniskipButtonKey: config.mpv.aniskipButtonKey,
@@ -1736,7 +1714,7 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
setSubsyncInProgress: (inProgress) => { setSubsyncInProgress: (inProgress) => {
appState.subsyncInProgress = inProgress; appState.subsyncInProgress = inProgress;
}, },
showMpvOsd: (text) => showSubsyncStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
openManualPicker: (payload) => { openManualPicker: (payload) => {
openOverlayHostedModalWithOsd( openOverlayHostedModalWithOsd(
(deps) => openSubsyncManualModalRuntime(deps, payload), (deps) => openSubsyncManualModalRuntime(deps, payload),
@@ -1758,10 +1736,7 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
const startupOsdSequencer = createStartupOsdSequencer({ const startupOsdSequencer = createStartupOsdSequencer({
getNotificationType: () => getConfiguredStatusNotificationType(),
showOsd: (message) => showMpvOsd(message), showOsd: (message) => showMpvOsd(message),
showOverlayNotification,
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
}); });
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
@@ -1792,21 +1767,11 @@ function isYoutubePlaybackActiveNow(): boolean {
} }
function reportYoutubeSubtitleFailure(message: string): void { function reportYoutubeSubtitleFailure(message: string): void {
const type = getConfiguredStatusNotificationType(); const type = getResolvedConfig().ankiConnect.behavior.notificationType;
if (type === 'none') { if (type === 'osd' || type === 'both') {
return;
}
if (type === 'overlay' || type === 'both') {
showOverlayNotification({
title: 'SubMiner',
body: message,
variant: 'warning',
});
}
if (type === 'osd' || type === 'osd-system') {
showMpvOsd(message); showMpvOsd(message);
} }
if (type === 'system' || type === 'both' || type === 'osd-system') { if (type === 'system' || type === 'both') {
try { try {
showDesktopNotification('SubMiner', { body: message }); showDesktopNotification('SubMiner', { body: message });
} catch { } catch {
@@ -1817,22 +1782,13 @@ function reportYoutubeSubtitleFailure(message: string): void {
async function openYoutubeTrackPickerFromPlayback(): Promise<void> { async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
if (youtubeFlowRuntime.hasActiveSession()) { if (youtubeFlowRuntime.hasActiveSession()) {
showConfiguredStatusNotification('YouTube subtitle flow already in progress.', { showMpvOsd('YouTube subtitle flow already in progress.');
title: 'YouTube subtitles',
variant: 'warning',
});
return; return;
} }
const currentMediaPath = const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) { if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
showConfiguredStatusNotification( showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
'YouTube subtitle picker is only available during YouTube playback.',
{
title: 'YouTube subtitles',
variant: 'warning',
},
);
return; return;
} }
await youtubeFlowRuntime.openManualPicker({ await youtubeFlowRuntime.openManualPicker({
@@ -1905,16 +1861,10 @@ async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
function signalCurrentSubtitleAutoplayReady(): void { function signalCurrentSubtitleAutoplayReady(): void {
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
const payload = getCurrentAutoplaySubtitlePayload(); const payload = getCurrentAutoplaySubtitlePayload();
if (payload) { if (!payload) {
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
return; return;
} }
if (!appState.currentSubText.trim()) { autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
}
} }
const buildSubtitleProcessingControllerMainDepsHandler = const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({ createBuildSubtitleProcessingControllerMainDepsHandler({
@@ -1947,8 +1897,6 @@ let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
let autoplaySubtitlePrimedMediaPath: string | null = null; let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
function getCurrentAutoplayMediaPath(): string | null { function getCurrentAutoplayMediaPath(): string | null {
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
@@ -2023,7 +1971,6 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(text); subtitleProcessingController.refreshCurrentSubtitle(text);
}, },
deferUncachedRefresh: true,
emitSubtitle: (payload) => emitSubtitlePayload(payload), emitSubtitle: (payload) => emitSubtitlePayload(payload),
setCurrentSecondarySubText: (text) => { setCurrentSecondarySubText: (text) => {
if (appState.mpvClient) { if (appState.mpvClient) {
@@ -2039,38 +1986,6 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
}); });
} }
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
return;
}
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
}
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
return;
}
if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) {
return;
}
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
if (!overlayManager.getVisibleOverlayVisible()) {
return;
}
const text = appState.currentSubText;
if (!text.trim()) {
return;
}
subtitlePrefetchService?.pause();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(text);
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
}
async function primeAutoplaySubtitleFromParsedCues( async function primeAutoplaySubtitleFromParsedCues(
mediaPath: string, mediaPath: string,
cues: SubtitleCue[], cues: SubtitleCue[],
@@ -2219,7 +2134,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
return windowTracker.isTargetWindowFocused(); return windowTracker.isTargetWindowFocused();
}, },
showMpvOsd: (text: string) => showConfiguredStatusNotification(text), showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette(); openRuntimeOptionsPalette();
}, },
@@ -2262,9 +2177,7 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{ {
getNotificationType: () => getConfiguredStatusNotificationType(),
showMpvOsd: (message) => showMpvOsd(message), showMpvOsd: (message) => showMpvOsd(message),
showOverlayNotification,
showDesktopNotification: (title, options) => showDesktopNotification(title, options), showDesktopNotification: (title, options) => showDesktopNotification(title, options),
}, },
); );
@@ -2620,9 +2533,8 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
onSyncStatus: (event) => { onSyncStatus: (event) => {
notifyCharacterDictionaryAutoSyncStatus(event, { notifyCharacterDictionaryAutoSyncStatus(event, {
getNotificationType: () => getConfiguredStatusNotificationType(), getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
showOsd: (message) => showMpvOsd(message), showOsd: (message) => showMpvOsd(message),
showOverlayNotification,
showDesktopNotification: (title, options) => showDesktopNotification(title, options), showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startupOsdSequencer, startupOsdSequencer,
}); });
@@ -2699,10 +2611,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
isMacOSPlatform: () => process.platform === 'darwin', isMacOSPlatform: () => process.platform === 'darwin',
isWindowsPlatform: () => process.platform === 'win32', isWindowsPlatform: () => process.platform === 'win32',
showOverlayLoadingOsd: (message: string) => { showOverlayLoadingOsd: (message: string) => {
showOverlayLoadingStatusNotification(message); showMpvOsd(message);
},
dismissOverlayLoadingOsd: () => {
dismissOverlayLoadingStatusNotification();
}, },
hideNonNativeOverlayWhenTargetUnfocused: () => hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() && shouldRunLinuxOverlayZOrderKeepAlive() &&
@@ -2731,7 +2640,6 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the // subtitle pointer interaction. Right after playback starts the overlay can briefly become the
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). // X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
@@ -2746,8 +2654,6 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState =
}; };
let visibleOverlayInteractionActive = false; let visibleOverlayInteractionActive = false;
let linuxOverlayInputShapeActive = false; let linuxOverlayInputShapeActive = false;
let linuxVisibleOverlayStartupInputPrimed = false;
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal // Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor // region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
// moves off measured subtitle/sidebar rects onto the popup. // moves off measured subtitle/sidebar rects onto the popup.
@@ -2770,7 +2676,6 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa
function resetVisibleOverlayInputState(): void { function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false; visibleOverlayInteractionActive = false;
linuxOverlayInputShapeActive = false; linuxOverlayInputShapeActive = false;
resetLinuxVisibleOverlayStartupInputPrimer();
linuxOverlayInteractiveHint = false; linuxOverlayInteractiveHint = false;
overlayContentMeasurementStore.clear('visible'); overlayContentMeasurementStore.clear('visible');
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
@@ -3243,23 +3148,6 @@ function shouldUseLinuxOverlayInputShape(): boolean {
return false; return false;
} }
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
return (
process.platform === 'linux' &&
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
);
}
function clearLinuxVisibleOverlayStartupInputGrace(): void {
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
}
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
linuxVisibleOverlayStartupInputPrimed = false;
clearLinuxVisibleOverlayStartupInputGrace();
}
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
if (!shouldUseLinuxOverlayInputShape()) { if (!shouldUseLinuxOverlayInputShape()) {
linuxOverlayInputShapeActive = false; linuxOverlayInputShapeActive = false;
@@ -3298,28 +3186,6 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
} }
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
if (process.platform !== 'linux') return;
if (linuxVisibleOverlayStartupInputPrimed) return;
if (shouldUseLinuxOverlayInputShape()) return;
if (
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
})
) {
return;
}
linuxVisibleOverlayStartupInputPrimed = true;
linuxVisibleOverlayStartupInputGraceUntilMs =
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
updateLinuxOverlayPointerInteractionActive(true);
}
const linuxOverlayZOrderKeepAliveDeps = { const linuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
@@ -3380,8 +3246,7 @@ const linuxOverlayPointerInteractionDeps = {
getCursorScreenPoint: () => getCursorScreenPoint: () =>
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
getRendererInteractiveHint: () => getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
shouldUseInputShape: shouldUseLinuxOverlayInputShape, shouldUseInputShape: shouldUseLinuxOverlayInputShape,
@@ -3428,177 +3293,6 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
overlayManager.broadcastToOverlayWindows(channel, ...args); overlayManager.broadcastToOverlayWindows(channel, ...args);
} }
function isVisibleOverlayContentReady(): boolean {
const overlayWindow = overlayManager.getMainWindow();
return Boolean(
overlayManager.getVisibleOverlayVisible() &&
overlayWindow &&
isOverlayWindowReadyForNotification(overlayWindow),
);
}
function getConfiguredStatusNotificationType(): NotificationType {
const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType;
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
}
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
return false;
}
if (window.webContents.isLoading()) {
return false;
}
const currentURL = window.webContents.getURL();
return currentURL !== '' && currentURL !== 'about:blank';
}
const overlayNotificationDelivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
send: (payload) => {
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
},
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
});
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = null;
function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush();
}
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
overlayNotificationDelivery.send(payload);
}
function showOverlayNotification(payload: OverlayNotificationPayload): void {
sendOverlayNotificationEvent(
withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()),
);
}
function dismissOverlayNotification(id: string): void {
sendOverlayNotificationEvent({ id, dismiss: true });
}
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId);
if (activeIntegrationOpen) {
await activeIntegrationOpen;
return;
}
const resolvedConfig = getResolvedConfig();
const effectiveAnkiConfig =
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const fallbackClient = new AnkiConnectClient(
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
);
await fallbackClient.openNoteInBrowser(noteId);
}
function toggleNotificationHistoryPanel(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
}
function showConfiguredStatusNotification(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
notifyConfiguredStatus(
message,
{
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
isOverlayReady: () => isVisibleOverlayContentReady(),
showOsd: (text) => showMpvOsd(text),
showOverlayNotification,
showDesktopNotification: (title, notificationOptions) =>
showDesktopNotification(title, notificationOptions),
},
options,
);
}
function showConfiguredPlaybackFeedback(
message: string,
options: ConfiguredStatusNotificationOptions = {},
): void {
showConfiguredStatusNotification(message, {
...getPlaybackFeedbackNotificationOptions(message),
...options,
delivery: 'feedback',
});
}
function showSubsyncStatusNotification(message: string): void {
const syncing = message.startsWith('Subsync: syncing');
const failed = message.toLowerCase().includes('failed');
showConfiguredStatusNotification(message, {
id: 'subsync-status',
title: 'Subsync',
variant: failed ? 'error' : syncing ? 'progress' : 'info',
persistent: syncing,
desktop: !syncing,
});
}
function showYoutubeFlowStatusNotification(message: string): void {
const progress =
message.startsWith('Downloading subtitles') ||
message.startsWith('Loading subtitles') ||
message.startsWith('Getting subtitles') ||
message === 'Opening YouTube video';
showConfiguredStatusNotification(message, {
id: 'youtube-subtitles-status',
title: 'YouTube subtitles',
variant: progress ? 'progress' : 'info',
persistent: progress,
desktop: !progress,
});
}
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
if (!overlayLoadingOsdController) {
overlayLoadingOsdController = createOverlayLoadingOsdController({
showOsd: (message) => {
showMpvOsd(message);
},
clearOsd: () => {
sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']);
},
setInterval: (callback, delayMs) => {
const timer = setInterval(callback, delayMs);
timer.unref?.();
return timer;
},
clearInterval: (timer) => {
clearInterval(timer as ReturnType<typeof setInterval>);
},
});
}
return overlayLoadingOsdController;
}
function showOverlayLoadingStatusNotification(message: string): void {
void message;
getOverlayLoadingOsdController().start();
}
function dismissOverlayLoadingStatusNotification(): void {
getOverlayLoadingOsdController().stop();
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']);
dismissOverlayNotification('overlay-loading-status');
}
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(),
isOverlayContentReady: () => isVisibleOverlayContentReady(),
startOverlayLoadingOsd: () => {
showOverlayLoadingStatusNotification('Overlay loading...');
},
});
const buildBroadcastRuntimeOptionsChangedMainDepsHandler = const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
broadcastRuntimeOptionsChangedRuntime, broadcastRuntimeOptionsChangedRuntime,
@@ -3689,12 +3383,12 @@ function openOverlayHostedModalWithOsd(
void openModal(createOverlayHostedModalOpenDeps()) void openModal(createOverlayHostedModalOpenDeps())
.then((opened) => { .then((opened) => {
if (!opened) { if (!opened) {
showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' }); showMpvOsd(unavailableMessage);
} }
}) })
.catch((error) => { .catch((error) => {
logger.error(failureLogMessage, error); logger.error(failureLogMessage, error);
showConfiguredStatusNotification(unavailableMessage, { variant: 'error' }); showMpvOsd(unavailableMessage);
}); });
} }
@@ -3725,7 +3419,7 @@ function openSessionHelpOverlay(): void {
function openCharacterDictionaryManagerOverlay(): void { function openCharacterDictionaryManagerOverlay(): void {
openCharacterDictionaryManagerWithConfigGate({ openCharacterDictionaryManagerWithConfigGate({
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getNotificationType: () => getConfiguredStatusNotificationType(), getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
openManager: () => { openManager: () => {
openOverlayHostedModalWithOsd( openOverlayHostedModalWithOsd(
openCharacterDictionaryManagerModalRuntime, openCharacterDictionaryManagerModalRuntime,
@@ -3734,7 +3428,6 @@ function openCharacterDictionaryManagerOverlay(): void {
); );
}, },
showOsd: (message) => showMpvOsd(message), showOsd: (message) => showMpvOsd(message),
showOverlayNotification,
showDesktopNotification: (title, options) => showDesktopNotification(title, options), showDesktopNotification: (title, options) => showDesktopNotification(title, options),
logWarn: (message, error) => logger.warn(message, error), logWarn: (message, error) => logger.warn(message, error),
}); });
@@ -3758,10 +3451,7 @@ function openControllerDebugOverlay(): void {
function openPlaylistBrowser(): void { function openPlaylistBrowser(): void {
if (!appState.mpvClient?.connected) { if (!appState.mpvClient?.connected) {
showConfiguredStatusNotification('Playlist browser requires active playback.', { showMpvOsd('Playlist browser requires active playback.');
title: 'Playlist browser',
variant: 'warning',
});
return; return;
} }
openOverlayHostedModalWithOsd( openOverlayHostedModalWithOsd(
@@ -3943,7 +3633,7 @@ const {
void appState.jellyfinRemoteSession?.reportPlaying(payload); void appState.jellyfinRemoteSession?.reportPlaying(payload);
}, },
showMpvOsd: (text) => { showMpvOsd: (text) => {
showConfiguredStatusNotification(text, { title: 'Jellyfin' }); showMpvOsd(text);
}, },
updateCurrentMediaTitle: (title) => { updateCurrentMediaTitle: (title) => {
mediaRuntime.updateCurrentMediaTitle(title); mediaRuntime.updateCurrentMediaTitle(title);
@@ -4077,7 +3767,7 @@ const {
}), }),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logError: (message, error) => logger.error(message, error), logError: (message, error) => logger.error(message, error),
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }), showMpvOsd: (message) => showMpvOsd(message),
clearSetupWindow: () => { clearSetupWindow: () => {
appState.jellyfinSetupWindow = null; appState.jellyfinSetupWindow = null;
}, },
@@ -4245,10 +3935,8 @@ const {
registerSubminerProtocolClient, registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({ } = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
getNotificationType: () => getConfiguredStatusNotificationType(),
hasMpvClient: () => Boolean(appState.mpvClient), hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), showMpvOsd: (message) => showMpvOsd(message),
showOverlayNotification,
showDesktopNotification: (title, options) => showDesktopNotification(title, options), showDesktopNotification: (title, options) => showDesktopNotification(title, options),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
@@ -4575,7 +4263,7 @@ const {
rememberAttemptedUpdateKey: (key) => { rememberAttemptedUpdateKey: (key) => {
rememberAnilistAttemptedUpdate(key); rememberAnilistAttemptedUpdate(key);
}, },
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), showMpvOsd: (message) => showMpvOsd(message),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS,
@@ -5248,8 +4936,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
shouldUseMinimalStartup: () => shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup, shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
createImmersionTracker: () => { createImmersionTracker: () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },
@@ -5328,7 +5014,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined)
const { const {
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
tokenizeSubtitle: tokenizeSubtitleRuntime, tokenizeSubtitle,
createMecabTokenizerAndCheck, createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries, prewarmSubtitleDictionaries,
startBackgroundWarmups, startBackgroundWarmups,
@@ -5351,7 +5037,6 @@ const {
void reportJellyfinRemoteStopped(); void reportJellyfinRemoteStopped();
}, },
onMpvConnected: () => { onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
if (appState.sessionBindingsInitialized) { if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [ sendMpvCommandRuntime(appState.mpvClient, [
'script-message', 'script-message',
@@ -5389,7 +5074,6 @@ const {
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim(); const normalizedPath = path.trim();
maybeStartOverlayLoadingOsd(normalizedPath);
const previousPath = appState.currentMediaPath?.trim() || null; const previousPath = appState.currentMediaPath?.trim() || null;
const preserveParsedSubtitleCues = isSameYoutubeMediaPath( const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
normalizedPath, normalizedPath,
@@ -5645,13 +5329,13 @@ const {
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup: () =>
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
showMpvOsd: (message: string) => showConfiguredStatusNotification(message), showMpvOsd: (message: string) => showMpvOsd(message),
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
showLoadedOsd: (message: string) => showLoadedOsd: (message: string) =>
startupOsdSequencer.markAnnotationLoadingComplete(message), startupOsdSequencer.markAnnotationLoadingComplete(message),
shouldShowOsdNotification: () => { shouldShowOsdNotification: () => {
const type = getConfiguredStatusNotificationType(); const type = getResolvedConfig().ankiConnect.behavior.notificationType;
return type === 'osd' || type === 'osd-system'; return type === 'osd' || type === 'both';
}, },
}, },
}, },
@@ -5704,14 +5388,6 @@ const {
}, },
}, },
}); });
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
if (!isTokenizationWarmupReady()) {
startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...');
}
return await tokenizeSubtitleRuntime(text);
}
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => isTokenizationWarmupReady(), isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
startTokenizationWarmups: async () => { startTokenizationWarmups: async () => {
@@ -6183,7 +5859,8 @@ function openYomitanSettings(): boolean {
logger.warn( logger.warn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
); );
showConfiguredStatusNotification(message, { variant: 'warning' }); showDesktopNotification('SubMiner', { body: message });
showMpvOsd(message);
return false; return false;
} }
openYomitanSettingsHandler(); openYomitanSettingsHandler();
@@ -6270,7 +5947,7 @@ const {
}, },
numericShortcutRuntimeMainDeps: { numericShortcutRuntimeMainDeps: {
globalShortcut, globalShortcut,
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer), clearTimer: (timer) => clearTimeout(timer),
}, },
@@ -6505,7 +6182,6 @@ function getUpdateService() {
{ notificationType: getResolvedConfig().updates.notificationType, version }, { notificationType: getResolvedConfig().updates.notificationType, version },
{ {
showSystemNotification: (title, body) => showDesktopNotification(title, { body }), showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
showOverlayNotification,
showOsdNotification: (message) => { showOsdNotification: (message) => {
showMpvOsd(message); showMpvOsd(message);
}, },
@@ -6530,7 +6206,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
broadcastToOverlayWindows: (channel, mode) => { broadcastToOverlayWindows: (channel, mode) => {
broadcastToOverlayWindows(channel, mode); broadcastToOverlayWindows(channel, mode);
}, },
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text: string) => showMpvOsd(text),
}, },
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
}); });
@@ -6567,7 +6243,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler =
createBuildUpdateLastCardFromClipboardMainDepsHandler({ createBuildUpdateLastCardFromClipboardMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
readClipboardText: () => clipboard.readText(), readClipboardText: () => clipboard.readText(),
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
updateLastCardFromClipboardCore, updateLastCardFromClipboardCore,
}); });
const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler();
@@ -6586,7 +6262,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler(
const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
triggerFieldGroupingCore, triggerFieldGroupingCore,
}); });
const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler();
@@ -6595,7 +6271,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie
const buildMarkLastCardAsAudioCardMainDepsHandler = const buildMarkLastCardAsAudioCardMainDepsHandler =
createBuildMarkLastCardAsAudioCardMainDepsHandler({ createBuildMarkLastCardAsAudioCardMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
markLastCardAsAudioCardCore, markLastCardAsAudioCardCore,
}); });
const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler();
@@ -6606,7 +6282,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
mineSentenceCardCore, mineSentenceCardCore,
recordCardsMined: (count, noteIds) => { recordCardsMined: (count, noteIds) => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
@@ -6620,7 +6296,7 @@ const mineSentenceCardHandler = createMineSentenceCardHandler(
const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({ const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text), writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text) => showMpvOsd(text),
handleMultiCopyDigitCore, handleMultiCopyDigitCore,
}); });
const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler(); const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler();
@@ -6629,7 +6305,7 @@ const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMult
const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({ const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({
getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
writeClipboardText: (text) => clipboard.writeText(text), writeClipboardText: (text) => clipboard.writeText(text),
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
copyCurrentSubtitleCore, copyCurrentSubtitleCore,
}); });
const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler();
@@ -6640,7 +6316,7 @@ const buildHandleMineSentenceDigitMainDepsHandler =
getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
logError: (message, err) => { logError: (message, err) => {
logger.error(message, err); logger.error(message, err);
}, },
@@ -6683,7 +6359,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler =
appendClipboardVideoToQueueRuntime, appendClipboardVideoToQueueRuntime,
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
readClipboardText: () => clipboard.readText(), readClipboardText: () => clipboard.readText(),
showMpvOsd: (text) => showConfiguredStatusNotification(text), showMpvOsd: (text) => showMpvOsd(text),
sendMpvCommand: (command) => { sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command); sendMpvCommandRuntime(appState.mpvClient, command);
}, },
@@ -6822,7 +6498,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
logger.warn('Failed to save Jellyfin subtitle delay.'); logger.warn('Failed to save Jellyfin subtitle delay.');
} }
}, },
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> { async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
@@ -6848,7 +6524,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
mineSentenceCount: (count) => handleMineSentenceDigit(count), mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(), toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(), toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
markActiveVideoWatched: async () => { markActiveVideoWatched: async () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
@@ -6880,12 +6555,12 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
} }
return applyRuntimeOptionResultRuntime( return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction), appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showConfiguredPlaybackFeedback(text), (text) => showMpvOsd(text),
); );
}, },
playNextPlaylistItem: () => playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']), sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
} }
@@ -6907,11 +6582,10 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
} }
return applyRuntimeOptionResultRuntime( return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction), appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showConfiguredPlaybackFeedback(text), (text) => showMpvOsd(text),
); );
}, },
showMpvOsd: (text: string) => showConfiguredStatusNotification(text), showMpvOsd: (text: string) => showMpvOsd(text),
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubDelayToAdjacentSubtitle: (direction) =>
@@ -6927,7 +6601,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
registration: { registration: {
runtimeOptions: { runtimeOptions: {
getRuntimeOptionsManager: () => appState.runtimeOptionsManager, getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text: string) => showMpvOsd(text),
}, },
mainDeps: { mainDeps: {
getMainWindow: () => overlayManager.getMainWindow(), getMainWindow: () => overlayManager.getMainWindow(),
@@ -6997,30 +6671,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
linuxOverlayInteractiveHint = interactive; linuxOverlayInteractiveHint = interactive;
applyLinuxOverlayInputShapeFromLatestMeasurement(); applyLinuxOverlayInputShapeFromLatestMeasurement();
}, },
handleOverlayNotificationAction: (notificationId, actionId, noteId) => {
if (
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
actionId === INSTALL_UPDATE_ACTION_ID
) {
void getUpdateService()
.checkForUpdates({
source: 'manual',
installWhenAvailable: true,
})
.catch((error) => {
logger.warn('Failed to install update from overlay notification action:', error);
});
}
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
void openAnkiCardFromNotification(noteId).catch((error) => {
logger.warn('Failed to open Anki card from overlay notification action:', error);
showConfiguredStatusNotification('Failed to open Anki card in Anki.', {
id: 'open-anki-card-failed',
variant: 'error',
});
});
}
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context), recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
@@ -7032,14 +6682,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
currentSubText: appState.currentSubText, currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData, currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
tokenizeUncached: false,
tokenizeSubtitle: tokenizeSubtitleForCurrent tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text) ? (text) => tokenizeSubtitleForCurrent(text)
: undefined, : undefined,
onResolvedSubtitle: (payload) => {
appState.currentSubtitleData = payload;
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
},
}); });
}, },
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
@@ -7163,7 +6808,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
dispatchSessionAction: (request) => dispatchSessionAction(request), dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition,
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
saveControllerConfig: (update) => { saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig(); const currentRawConfig = configService.getRawConfig();
@@ -7186,9 +6830,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
reportOverlayContentBounds: (payload: unknown) => { reportOverlayContentBounds: (payload: unknown) => {
if (overlayContentMeasurementStore.report(payload)) { if (overlayContentMeasurementStore.report(payload)) {
tickLinuxOverlayPointerInteractionNow(); tickLinuxOverlayPointerInteractionNow();
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
} }
}, },
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
@@ -7296,7 +6938,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}, },
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification, showDesktopNotification,
showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
getFieldGroupingResolver: () => getFieldGroupingResolver(), getFieldGroupingResolver: () => getFieldGroupingResolver(),
@@ -7333,7 +6974,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
openExternal: (url: string) => shell.openExternal(url), openExternal: (url: string) => shell.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error), logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
showMpvOsd: (text: string) => showConfiguredStatusNotification(text), showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
@@ -7558,14 +7199,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
linuxVisibleOverlayWindowMode === 'fullscreen-override', linuxVisibleOverlayWindowMode === 'fullscreen-override',
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
onWindowDidFinishLoad: () => {
flushQueuedOverlayNotifications();
},
onWindowContentReady: () => { onWindowContentReady: () => {
dismissOverlayLoadingStatusNotification();
flushQueuedOverlayNotifications();
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
autoplayReadyGate.flushPendingAutoplayReadySignal(); autoplayReadyGate.flushPendingAutoplayReadySignal();
}, },
onWindowClosed: (windowKind, window) => { onWindowClosed: (windowKind, window) => {
@@ -7604,8 +7242,7 @@ function getJellyfinTrayDiscoveryDeps() {
startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options), startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
refreshTrayMenu: () => refreshTrayMenuIfPresent(), refreshTrayMenu: () => refreshTrayMenuIfPresent(),
logger, logger,
showMpvOsd: (message: string) => showMpvOsd: (message: string) => showMpvOsd(message),
showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
}; };
} }
@@ -7752,7 +7389,6 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
getOverlayWindows: () => getOverlayWindows(), getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification, showDesktopNotification,
showOverlayNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
shouldStartAnkiIntegration: () => shouldStartAnkiIntegration: () =>
@@ -7844,15 +7480,11 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
} }
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -7866,14 +7498,10 @@ function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
const nextVisible = !overlayManager.getVisibleOverlayVisible(); const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) { if (!nextVisible) {
dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
} else { } else {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
@@ -7884,15 +7512,11 @@ function toggleVisibleOverlay(): void {
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
dismissOverlayLoadingStatusNotification();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
} }
if (visible) { if (visible) {
maybeStartOverlayLoadingOsd();
resetLinuxVisibleOverlayStartupInputPrimer();
restoreVisibleOverlayWindowShapeForShow(); restoreVisibleOverlayWindowShapeForShow();
void ensureOverlayMpvSubtitlesHidden(); void ensureOverlayMpvSubtitlesHidden();
void primeCurrentSubtitleForVisibleOverlay(); void primeCurrentSubtitleForVisibleOverlay();
-3
View File
@@ -63,7 +63,6 @@ export interface AppReadyRuntimeDepsFactoryInput {
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand']; shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup']; shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup']; shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: AppReadyRuntimeDeps['shouldHandleInitialArgsBeforeDeferredOverlayWarmup'];
} }
export function createAppLifecycleRuntimeDeps( export function createAppLifecycleRuntimeDeps(
@@ -134,8 +133,6 @@ export function createAppReadyRuntimeDeps(
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand, shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: params.shouldUseMinimalStartup, shouldUseMinimalStartup: params.shouldUseMinimalStartup,
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup, shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
}; };
} }
-9
View File
@@ -1,5 +1,4 @@
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types'; import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
import type { OverlayNotificationPayload } from '../types/notification';
import { SubsyncResolvedConfig } from '../subsync/utils'; import { SubsyncResolvedConfig } from '../subsync/utils';
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner'; import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
import type { IpcDepsRuntimeOptions } from '../core/services/ipc'; import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
@@ -60,7 +59,6 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged']; onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint']; onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp']; quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -84,7 +82,6 @@ export interface MainIpcRuntimeServiceDepsParams {
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig']; saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
@@ -127,7 +124,6 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -225,7 +221,6 @@ export interface MpvCommandRuntimeServiceDepsParams {
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle']; shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
@@ -245,7 +240,6 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayModalOpened: params.onOverlayModalOpened, onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged, onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: params.onOverlayInteractiveHint, onOverlayInteractiveHint: params.onOverlayInteractiveHint,
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve, onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings, openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp, quitApp: params.quitApp,
@@ -267,7 +261,6 @@ export function createMainIpcRuntimeServiceDeps(
dispatchSessionAction: params.dispatchSessionAction, dispatchSessionAction: params.dispatchSessionAction,
getStatsToggleKey: params.getStatsToggleKey, getStatsToggleKey: params.getStatsToggleKey,
getMarkWatchedKey: params.getMarkWatchedKey, getMarkWatchedKey: params.getMarkWatchedKey,
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
getControllerConfig: params.getControllerConfig, getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig, saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference, saveControllerPreference: params.saveControllerPreference,
@@ -316,7 +309,6 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
setAnkiIntegration: params.setAnkiIntegration, setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
showDesktopNotification: params.showDesktopNotification, showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback, createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver, getFieldGroupingResolver: params.getFieldGroupingResolver,
@@ -422,7 +414,6 @@ export function createMpvCommandRuntimeServiceDeps(
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
showPlaybackFeedback: params.showPlaybackFeedback,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle, shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
-2
View File
@@ -17,7 +17,6 @@ export interface MpvCommandFromIpcRuntimeDeps {
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
playNextSubtitle: () => void; playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>; shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
@@ -42,7 +41,6 @@ export function handleMpvCommandFromIpcRuntime(
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
showPlaybackFeedback: deps.showPlaybackFeedback,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle,
shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubDelayToAdjacentSubtitle: (direction) =>
+12 -163
View File
@@ -59,50 +59,6 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
); );
}); });
test('mpv startup signals start overlay loading OSD before readiness work', () => {
const source = readMainSource();
const connectedBlock = source.match(
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
const mediaPathBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(connectedBlock);
assert.ok(mediaPathBlock);
assert.ok(setVisibleBlock);
assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/);
assert.match(
mediaPathBlock,
/const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/,
);
assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/);
assert.match(
source,
/function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
assert.match(
source,
/function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
});
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
const source = readMainSource();
const dismissBlock = source.match(
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(dismissBlock);
assert.match(
dismissBlock,
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
);
});
test('manual visible overlay toggles only release current-media autoplay when hiding', () => { test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -112,7 +68,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
assert.ok(actionBlock); assert.ok(actionBlock);
assert.match( assert.match(
actionBlock, actionBlock,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
); );
}); });
@@ -133,15 +89,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
assert.ok(setOverlayBlock); assert.ok(setOverlayBlock);
assert.match( assert.match(
setVisibleBlock, setVisibleBlock,
/if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, /if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
toggleBlock, toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
setOverlayBlock, setOverlayBlock,
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, /if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
); );
}); });
@@ -162,23 +118,6 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
); );
}); });
test('update overlay notification action triggers install flow', () => {
const source = readMainSource();
assert.match(
source,
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
);
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
assert.match(source, /installWhenAvailable:\s*true/);
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
assert.match(source, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/);
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
});
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
@@ -221,7 +160,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
); );
}); });
test('startup autoplay release is tied to visible overlay measurement readiness', () => { test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
const source = readMainSource(); const source = readMainSource();
const gateBlock = source.match( const gateBlock = source.match(
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/, /const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
@@ -232,7 +171,7 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
assert.ok(gateBlock); assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/); assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/); assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/); assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/); assert.match(gateBlock, /getLatestVisibleMeasurement:/);
@@ -241,37 +180,6 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
}); });
test('visible overlay content-ready does not tokenize before first measurement', () => {
const source = readMainSource();
const contentReadyBlock = source.match(
/onWindowContentReady:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(contentReadyBlock);
assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/);
assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok(
contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') <
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
assert.ok(
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') <
contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'),
);
assert.ok(measurementBlock);
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
assert.ok(
measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') <
measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'),
);
});
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => { test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
const source = readMainSource(); const source = readMainSource();
const measurementBlock = source.match( const measurementBlock = source.match(
@@ -281,15 +189,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
assert.ok(measurementBlock); assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok( assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'), measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
); );
assert.ok(
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
}); });
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => { test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
@@ -313,14 +216,11 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
assert.match(depsBlock, /subtitleSidebarRequestedOpen/); assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
}); });
test('warm tokenization release can signal readiness before the first subtitle appears', () => { test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
const source = readMainSource(); const source = readMainSource();
const warmReleaseBlock = source.match( const warmReleaseBlock = source.match(
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/, /signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body; )?.groups?.body;
const signalBlock = source.match(
/function signalCurrentSubtitleAutoplayReady\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const currentPayloadBlock = source.match( const currentPayloadBlock = source.match(
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/, /function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body; )?.groups?.body;
@@ -330,12 +230,7 @@ test('warm tokenization release can signal readiness before the first subtitle a
warmReleaseBlock, warmReleaseBlock,
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/, /signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
); );
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
assert.ok(signalBlock);
assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/);
assert.match(signalBlock, /if \(payload\) \{/);
assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/);
assert.match(signalBlock, /text: '__warm__'/);
assert.ok(currentPayloadBlock); assert.ok(currentPayloadBlock);
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/); assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
@@ -352,10 +247,7 @@ test('stats server Yomitan note creation honors configured Anki server override
)?.groups?.body; )?.groups?.body;
assert.ok(addYomitanNoteBlock); assert.ok(addYomitanNoteBlock);
assert.match( assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
addYomitanNoteBlock,
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
}); });
@@ -429,49 +321,6 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
}); });
test('manual visible overlay hide dismisses loading OSD', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.ok(setOverlayBlock);
assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
});
test('configured overlay notifications require visible ready overlay window', () => {
const source = readMainSource();
const readinessBlock = source.match(
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const statusBlock = source.match(
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(readinessBlock);
assert.ok(statusBlock);
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
});
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -485,11 +334,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
assert.ok(toggleBlock); assert.ok(toggleBlock);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
); );
assert.match( assert.match(
toggleBlock, toggleBlock,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, /else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
); );
}); });
@@ -508,7 +357,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match( assert.match(
setBlock, setBlock,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
); );
}); });
-2
View File
@@ -30,7 +30,6 @@ export interface OverlayVisibilityRuntimeDeps {
isMacOSPlatform: () => boolean; isMacOSPlatform: () => boolean;
isWindowsPlatform: () => boolean; isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void; showOverlayLoadingOsd: (message: string) => void;
dismissOverlayLoadingOsd?: () => void;
resolveFallbackBounds: () => WindowGeometry; resolveFallbackBounds: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean; hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
} }
@@ -81,7 +80,6 @@ export function createOverlayVisibilityRuntimeService(
isMacOSPlatform: deps.isMacOSPlatform(), isMacOSPlatform: deps.isMacOSPlatform(),
isWindowsPlatform: deps.isWindowsPlatform(), isWindowsPlatform: deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message), showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
shouldShowOverlayLoadingOsd: () => shouldShowOverlayLoadingOsd: () =>
lastOverlayLoadingOsdAtMs === null || lastOverlayLoadingOsdAtMs === null ||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS, Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
+2 -2
View File
@@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => { test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
const calls: string[] = []; const calls: string[] = [];
const attemptedKeys = new Set<string>(); const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv'; const mediaKey = '/tmp/video.mkv';
@@ -378,5 +378,5 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
assert.equal(calls.includes('update'), false); assert.equal(calls.includes('update'), false);
assert.equal(calls.includes('enqueue'), false); assert.equal(calls.includes('enqueue'), false);
assert.equal(calls.includes('mark-failure'), false); assert.equal(calls.includes('mark-failure'), false);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']); assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
}); });
+1 -4
View File
@@ -194,11 +194,8 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return; return;
} }
const retryResult = await deps.processNextAnilistRetryUpdate(); await deps.processNextAnilistRetryUpdate();
if (deps.hasAttemptedUpdateKey(attemptKey)) { if (deps.hasAttemptedUpdateKey(attemptKey)) {
if (retryResult.ok) {
deps.showMpvOsd(retryResult.message);
}
return; return;
} }
@@ -23,18 +23,6 @@ test('notify anilist setup main deps builder maps callbacks', () => {
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
}); });
test('notify anilist setup main deps builder preserves optional notification callbacks', () => {
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {},
})();
assert.equal(deps.getNotificationType, undefined);
assert.equal(deps.showOverlayNotification, undefined);
});
test('consume anilist setup token main deps builder maps callbacks', () => { test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
@@ -18,12 +18,8 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({ return (): NotifyAnilistSetupMainDeps => ({
getNotificationType: deps.getNotificationType ? () => deps.getNotificationType?.() : undefined,
hasMpvClient: () => deps.hasMpvClient(), hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message), showMpvOsd: (message: string) => deps.showMpvOsd(message),
showOverlayNotification: deps.showOverlayNotification
? (payload) => deps.showOverlayNotification?.(payload)
: undefined,
showDesktopNotification: (title: string, options: { body: string }) => showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options), deps.showDesktopNotification(title, options),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
@@ -19,24 +19,6 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
assert.deepEqual(calls, ['osd:AniList login success']); assert.deepEqual(calls, ['osd:AniList login success']);
}); });
test('createNotifyAnilistSetupHandler routes through configured notification surfaces', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
getNotificationType: () => 'both',
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, [
'overlay:SubMiner AniList:AniList login success:success',
'notify:SubMiner AniList:AniList login success',
]);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => { test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({ const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'), consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
@@ -1,5 +1,3 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
export type ConsumeAnilistSetupTokenDeps = { export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: { consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string; rawUrl: string;
@@ -32,35 +30,12 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
} }
export function createNotifyAnilistSetupHandler(deps: { export function createNotifyAnilistSetupHandler(deps: {
getNotificationType?: () => NotificationType | undefined;
hasMpvClient: () => boolean; hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void; showMpvOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void; showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}) { }) {
return (message: string): void => { return (message: string): void => {
const type = deps.getNotificationType?.();
if (type) {
if (type === 'none') {
return;
}
if (type === 'overlay' || type === 'both') {
deps.showOverlayNotification?.({
title: 'SubMiner AniList',
body: message,
variant: 'success',
});
}
if ((type === 'osd' || type === 'osd-system') && deps.hasMpvClient()) {
deps.showMpvOsd(message);
}
if (type === 'system' || type === 'both' || type === 'osd-system') {
deps.showDesktopNotification('SubMiner AniList', { body: message });
}
return;
}
if (deps.hasMpvClient()) { if (deps.hasMpvClient()) {
deps.showMpvOsd(message); deps.showMpvOsd(message);
return; return;
@@ -48,7 +48,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
startBackgroundWarmups: () => calls.push('start-warmups'), startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false, texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true, shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'), setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'), handleInitialArgs: () => calls.push('handle-initial-args'),
@@ -65,7 +64,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
assert.equal(onReady.defaultTexthookerPort, 5174); assert.equal(onReady.defaultTexthookerPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false); assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true);
assert.equal(onReady.now?.(), 123); assert.equal(onReady.now?.(), 123);
onReady.loadSubtitlePosition(); onReady.loadSubtitlePosition();
onReady.resolveKeybindings(); onReady.resolveKeybindings();
-2
View File
@@ -45,7 +45,5 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand, shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: deps.shouldUseMinimalStartup, shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup, shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
}); });
} }
@@ -314,100 +314,6 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea
); );
}); });
test('autoplay ready gate retries deferred readiness without an external flush event', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let targetReady = false;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
assert.equal(scheduled.length, 1);
targetReady = true;
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => false,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
for (let attempt = 1; attempt <= 100; attempt += 1) {
assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands, []);
});
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => { test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
const commands: Array<Array<string | boolean>> = []; const commands: Array<Array<string | boolean>> = [];
let targetReady = false; let targetReady = false;
+3 -45
View File
@@ -1,9 +1,6 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy'; import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200;
const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150;
type MpvClientLike = { type MpvClientLike = {
connected?: boolean; connected?: boolean;
requestProperty: (property: string) => Promise<unknown>; requestProperty: (property: string) => Promise<unknown>;
@@ -37,22 +34,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0; let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null; let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
let pendingAutoplayReadyRetryToken = 0;
let pendingAutoplayReadyRetryAttempts = 0;
let scheduledPendingAutoplayReadyRetryToken: number | null = null;
const now = deps.now ?? (() => Date.now()); const now = deps.now ?? (() => Date.now());
const invalidatePendingAutoplayReadyRetry = (): void => {
pendingAutoplayReadyRetryToken += 1;
pendingAutoplayReadyRetryAttempts = 0;
scheduledPendingAutoplayReadyRetryToken = null;
};
const invalidatePendingAutoplayReadyFallbacks = (): void => { const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null; autoPlayReadySignalMediaPath = null;
pendingAutoplayReadySignal = null; pendingAutoplayReadySignal = null;
autoPlayReadySignalGeneration += 1; autoPlayReadySignalGeneration += 1;
invalidatePendingAutoplayReadyRetry();
}; };
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean => const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
@@ -65,43 +52,18 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
pendingAutoplayReadySignal = null; pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath(); autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1; autoPlayReadySignalGeneration += 1;
invalidatePendingAutoplayReadyRetry();
}; };
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => { const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
if ( if (
pendingAutoplayReadySignal && pendingAutoplayReadySignal &&
pendingAutoplayReadySignal.mediaPath === signal.mediaPath && pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
pendingAutoplayReadySignal.payload.text === signal.payload.text && pendingAutoplayReadySignal.payload.text === signal.payload.text &&
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
) { ) {
return false; return;
} }
pendingAutoplayReadySignal = signal; pendingAutoplayReadySignal = signal;
pendingAutoplayReadyRetryAttempts = 0;
return true;
};
const schedulePendingAutoplayReadyRetry = (): void => {
if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) {
return;
}
if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) {
return;
}
const retryToken = pendingAutoplayReadyRetryToken;
pendingAutoplayReadyRetryAttempts += 1;
scheduledPendingAutoplayReadyRetryToken = retryToken;
deps.schedule(() => {
if (scheduledPendingAutoplayReadyRetryToken === retryToken) {
scheduledPendingAutoplayReadyRetryToken = null;
}
if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) {
return;
}
flushPendingAutoplayReadySignal();
}, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS);
}; };
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => { const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
@@ -177,7 +139,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
}; };
pendingAutoplayReadySignal = null; pendingAutoplayReadySignal = null;
invalidatePendingAutoplayReadyRetry();
autoPlayReadySignalMediaPath = mediaPath; autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration; const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady(); deps.signalPluginAutoplayReady();
@@ -191,13 +152,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
if (!isSignalTargetReady(signal)) { if (!isSignalTargetReady(signal)) {
const pendingSignalChanged = setPendingAutoplayReadySignal(signal); setPendingAutoplayReadySignal(signal);
schedulePendingAutoplayReadyRetry();
if (pendingSignalChanged) {
deps.logDebug( deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`, `[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
); );
}
return; return;
} }
@@ -4,7 +4,6 @@ import {
notifyCharacterDictionaryAutoSyncStatus, notifyCharacterDictionaryAutoSyncStatus,
type CharacterDictionaryAutoSyncNotificationEvent, type CharacterDictionaryAutoSyncNotificationEvent,
} from './character-dictionary-auto-sync-notifications'; } from './character-dictionary-auto-sync-notifications';
import { createStartupOsdSequencer } from './startup-osd-sequencer';
function makeEvent( function makeEvent(
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'], phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
@@ -71,7 +70,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
]); ]);
}); });
test('auto sync notifications send overlay and desktop delivery for both', () => { test('auto sync notifications never send desktop notifications', () => {
const calls: string[] = []; const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
@@ -81,10 +80,14 @@ test('auto sync notifications send overlay and desktop delivery for both', () =>
}, },
showDesktopNotification: (title, options) => showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) => });
calls.push( notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, getNotificationType: () => 'both',
), showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'both', getNotificationType: () => 'both',
@@ -93,25 +96,9 @@ test('auto sync notifications send overlay and desktop delivery for both', () =>
}, },
showDesktopNotification: (title, options) => showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
),
}); });
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
assert.deepEqual(calls, [ getNotificationType: () => 'both',
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin',
'desktop:SubMiner:syncing',
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto',
'desktop:SubMiner:ready',
]);
});
test('auto sync notifications fall back to desktop when overlay routing is unavailable', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('building', 'building'), {
getNotificationType: () => undefined,
showOsd: (message) => { showOsd: (message) => {
calls.push(`osd:${message}`); calls.push(`osd:${message}`);
}, },
@@ -119,30 +106,14 @@ test('auto sync notifications fall back to desktop when overlay routing is unava
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['desktop:SubMiner:building']); assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
}); });
test('auto sync notifications keep osd-system on legacy surfaces', () => { test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
});
assert.deepEqual(calls, ['osd:syncing', 'desktop:SubMiner:syncing']);
});
test('auto sync notifications keep osd-system desktop delivery even when osd is unavailable', () => {
const calls: string[] = []; const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
getNotificationType: () => 'osd-system', getNotificationType: () => 'both',
showOsd: (message) => { showOsd: (message) => {
calls.push(`osd:${message}`); calls.push(`osd:${message}`);
return false; return false;
@@ -151,7 +122,7 @@ test('auto sync notifications keep osd-system desktop delivery even when osd is
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'osd-system', getNotificationType: () => 'both',
showOsd: (message) => { showOsd: (message) => {
calls.push(`osd:${message}`); calls.push(`osd:${message}`);
return false; return false;
@@ -160,19 +131,14 @@ test('auto sync notifications keep osd-system desktop delivery even when osd is
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, [ assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']);
'osd:generating',
'desktop:SubMiner:generating',
'osd:ready',
'desktop:SubMiner:ready',
]);
}); });
test('auto sync notifications send osd-system desktop updates with startup sequencer', () => { test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => {
const calls: string[] = []; const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'osd-system', getNotificationType: () => 'both',
showOsd: (message) => { showOsd: (message) => {
calls.push(`osd:${message}`); calls.push(`osd:${message}`);
}, },
@@ -188,29 +154,3 @@ test('auto sync notifications send osd-system desktop updates with startup seque
assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']); assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']);
}); });
test('auto sync notifications let startup sequencer own osd-system desktop delivery', () => {
const calls: string[] = [];
const startupOsdSequencer = createStartupOsdSequencer({
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body ?? ''}`);
},
});
startupOsdSequencer.markTokenizationReady();
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`direct-osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`direct-desktop:${title}:${options.body ?? ''}`),
startupOsdSequencer,
});
assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']);
});
@@ -1,14 +1,11 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync'; import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer'; import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent; export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
export interface CharacterDictionaryAutoSyncNotificationDeps { export interface CharacterDictionaryAutoSyncNotificationDeps {
getNotificationType: () => NotificationType | undefined; getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
showOsd: (message: string) => boolean | void; showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void; showDesktopNotification: (title: string, options: { body?: string }) => void;
startupOsdSequencer?: { startupOsdSequencer?: {
notifyCharacterDictionaryStatus: ( notifyCharacterDictionaryStatus: (
@@ -17,58 +14,39 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
}; };
} }
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean { function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return phase === 'ready' || phase === 'failed'; return type !== 'none';
} }
function overlayVariantForPhase( function shouldFallbackToDesktop(
type: 'osd' | 'system' | 'both' | 'none' | undefined,
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'], phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
): OverlayNotificationPayload['variant'] { ): boolean {
if (phase === 'ready') return 'success'; return (
if (phase === 'failed') return 'error'; (type === 'system' || type === 'both') &&
return 'progress'; (phase === 'generating' || phase === 'building' || phase === 'importing')
} );
function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string {
const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current';
return `character-dictionary-auto-sync-${mediaId}-${event.phase}`;
} }
export function notifyCharacterDictionaryAutoSyncStatus( export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent, event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps, deps: CharacterDictionaryAutoSyncNotificationDeps,
): void { ): void {
const type = deps.getNotificationType() ?? 'overlay'; const type = deps.getNotificationType();
if (type === 'none') return;
let startupSequencerShown = false;
if (shouldShowOverlay(type)) {
if (deps.showOverlayNotification) {
deps.showOverlayNotification({
id: 'character-dictionary-auto-sync',
historyId: historyIdForEvent(event),
title: 'Character dictionary',
body: event.message,
variant: overlayVariantForPhase(event.phase),
persistent: !isTerminalPhase(event.phase),
});
} else if (!shouldShowDesktop(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}
if (shouldShowOsd(type)) { if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) { if (deps.startupOsdSequencer) {
startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase, phase: event.phase,
message: event.message, message: event.message,
}); });
} else { if (!shown && shouldFallbackToDesktop(type, event.phase)) {
deps.showOsd(event.message); deps.showDesktopNotification('SubMiner', { body: event.message });
} }
return;
} }
const shown = deps.showOsd(event.message) !== false;
if (shouldShowDesktop(type) && !startupSequencerShown) { if (!shown && shouldFallbackToDesktop(type, event.phase)) {
deps.showDesktopNotification('SubMiner', { body: event.message }); deps.showDesktopNotification('SubMiner', { body: event.message });
} }
} }
}
@@ -18,8 +18,6 @@ function makeDeps(options: {
getNotificationType: () => options.notificationType ?? 'osd', getNotificationType: () => options.notificationType ?? 'osd',
openManager: () => calls.push('open'), openManager: () => calls.push('open'),
showOsd: (message: string) => calls.push(`osd:${message}`), showOsd: (message: string) => calls.push(`osd:${message}`),
showOverlayNotification: (payload: { title: string; body?: string }) =>
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
showDesktopNotification: (title: string, opts: { body: string }) => showDesktopNotification: (title: string, opts: { body: string }) =>
calls.push(`system:${title}:${opts.body}`), calls.push(`system:${title}:${opts.body}`),
logWarn: (message: string) => calls.push(`warn:${message}`), logWarn: (message: string) => calls.push(`warn:${message}`),
@@ -41,13 +39,6 @@ test('routes disabled manager notification to configured surfaces', () => {
['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]], ['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
[ [
'both', 'both',
[
`overlay:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
],
],
[
'osd-system',
[ [
`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, `osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, `system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
@@ -1,6 +1,4 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
export type CharacterDictionaryManagerNotificationType = NotificationType;
export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE = export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE =
'Enable Name Match in Settings to use the character dictionary manager.'; 'Enable Name Match in Settings to use the character dictionary manager.';
@@ -10,27 +8,16 @@ export interface CharacterDictionaryManagerGateDeps {
getNotificationType: () => CharacterDictionaryManagerNotificationType; getNotificationType: () => CharacterDictionaryManagerNotificationType;
openManager: () => void; openManager: () => void;
showOsd: (message: string) => void; showOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void; showDesktopNotification: (title: string, options: { body: string }) => void;
logWarn?: (message: string, error?: unknown) => void; logWarn?: (message: string, error?: unknown) => void;
} }
function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void { function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void {
const type = deps.getNotificationType(); const type = deps.getNotificationType();
if (type === 'none') { if (type === 'osd' || type === 'both') {
return;
}
if (type === 'overlay' || type === 'both') {
deps.showOverlayNotification?.({
title: 'SubMiner',
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
variant: 'warning',
});
}
if (type === 'osd' || type === 'osd-system') {
deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE); deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE);
} }
if (type === 'system' || type === 'both' || type === 'osd-system') { if (type === 'system' || type === 'both') {
try { try {
deps.showDesktopNotification('SubMiner', { deps.showDesktopNotification('SubMiner', {
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE, body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
@@ -58,7 +58,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
dispatchSessionAction: async () => {}, dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote', getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW', getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => ({}) as never, getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {}, saveControllerConfig: () => {},
saveControllerPreference: () => {}, saveControllerPreference: () => {},
@@ -265,23 +265,6 @@ test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop not
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']); assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
}); });
test('createConfigHotReloadMessageHandler routes message through configured notification surfaces', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
getNotificationType: () => 'both',
showMpvOsd: (message) => calls.push(`osd:${message}`),
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, [
'overlay:SubMiner:Config reload failed:warning',
'notify:SubMiner:Config reload failed',
]);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => { test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal( assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']), buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),

Some files were not shown because too many files have changed in this diff Show More