mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ac1926a5dd
|
|||
|
efb7e9db97
|
|||
|
f534938d4b
|
|||
|
a092cbe2da
|
|||
|
2b0ce357f1
|
|||
|
14cd37d8d7
|
|||
|
9d77907877
|
|||
|
d033884b09
|
|||
|
501304e451
|
|||
|
ef914a321f
|
|||
|
0f8370a3a9
|
|||
|
a3b907adff
|
|||
|
71efbd1bc1
|
|||
|
c9acfff2bc
|
|||
|
5fbbffdcdd
|
|||
|
a01fc57053
|
|||
|
9247248d48
|
@@ -0,0 +1,24 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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.
|
||||||
+13
-3
@@ -172,10 +172,19 @@
|
|||||||
"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. Values: system | osd | both | none
|
"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
|
||||||
"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.
|
||||||
@@ -199,7 +208,8 @@
|
|||||||
"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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -539,7 +549,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": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
"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
|
||||||
"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": {
|
||||||
|
|||||||
@@ -216,11 +216,15 @@ 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": "osd" // "osd", "system", "both", or "none"
|
"notificationType": "overlay" // "overlay", "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
|
||||||
@@ -351,7 +355,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": "osd",
|
"notificationType": "overlay",
|
||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ 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?
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ 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
|
||||||
|
|
||||||
@@ -203,12 +204,38 @@ 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` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
|
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. `"both"` means overlay + 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:
|
||||||
@@ -223,7 +250,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.
|
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.
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
@@ -620,13 +647,14 @@ 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"`) |
|
||||||
@@ -645,6 +673,7 @@ 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.
|
||||||
|
|
||||||
@@ -944,7 +973,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) |
|
||||||
@@ -989,7 +1018,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` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
| `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.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`. |
|
||||||
@@ -1459,14 +1488,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 (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`) |
|
||||||
| `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"`) |
|
||||||
|
|||||||
@@ -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 and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
- **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).
|
||||||
- **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.
|
||||||
|
|||||||
@@ -172,10 +172,19 @@
|
|||||||
"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. Values: system | osd | both | none
|
"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
|
||||||
"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.
|
||||||
@@ -199,7 +208,8 @@
|
|||||||
"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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -539,7 +549,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": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
"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
|
||||||
"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": {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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` |
|
||||||
|
|||||||
@@ -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 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.
|
`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.
|
||||||
|
|
||||||
## AnkiConnect
|
## AnkiConnect
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!-- 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!-- 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,6 +45,7 @@ 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',
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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',
|
||||||
@@ -209,6 +210,7 @@ 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',
|
||||||
@@ -272,6 +274,7 @@ 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',
|
||||||
@@ -341,12 +344,14 @@ 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;
|
||||||
@@ -357,7 +362,19 @@ 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;
|
||||||
@@ -374,6 +391,7 @@ 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;
|
||||||
@@ -403,6 +421,7 @@ 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',
|
||||||
|
|||||||
@@ -232,6 +232,14 @@ 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 &&
|
||||||
@@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
backend: args.backend,
|
backend: args.backend,
|
||||||
|
overlayLoadingOsd: shouldShowOverlayLoadingOsd,
|
||||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -148,18 +153,32 @@ 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');
|
||||||
@@ -175,6 +194,7 @@ 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',
|
||||||
@@ -187,7 +207,10 @@ 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',
|
||||||
@@ -205,6 +228,7 @@ 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',
|
||||||
@@ -217,7 +241,10 @@ 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',
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ 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();
|
||||||
@@ -53,6 +58,7 @@ 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'),
|
||||||
@@ -72,7 +78,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}, 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}, osd_messages=${parsed.osdMessages}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||||
);
|
);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ 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',
|
||||||
@@ -405,6 +406,7 @@ 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',
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ 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;
|
||||||
|
|||||||
@@ -467,7 +467,9 @@ 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
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ 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
|
||||||
@@ -151,6 +159,14 @@ 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
|
||||||
@@ -178,6 +194,7 @@ 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
|
||||||
@@ -192,6 +209,9 @@ 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
|
||||||
@@ -245,6 +265,7 @@ 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
|
||||||
@@ -273,6 +294,7 @@ 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
|
||||||
@@ -291,6 +313,7 @@ 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
|
||||||
@@ -310,6 +333,7 @@ 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
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function show_osd(message)
|
local function show_osd(message, options)
|
||||||
if opts.osd_messages then
|
if opts.osd_messages or (options and options.force == true) 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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -43,6 +44,9 @@ 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)
|
||||||
@@ -56,7 +60,9 @@ 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()
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ 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 = 15,
|
auto_start_pause_until_ready_timeout_seconds = 30,
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
log_level = "info",
|
||||||
aniskip_enabled = false,
|
aniskip_enabled = false,
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ 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 = 15
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||||
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
@@ -53,6 +56,14 @@ 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
|
||||||
@@ -246,6 +257,42 @@ 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
|
||||||
@@ -264,8 +311,11 @@ 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
|
||||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
if resolve_osd_messages_enabled() then
|
||||||
|
stop_overlay_loading_osd()
|
||||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||||
|
end
|
||||||
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
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"))
|
||||||
@@ -287,8 +337,11 @@ 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)
|
||||||
if type(mp.add_periodic_timer) == "function" then
|
end
|
||||||
|
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)
|
||||||
@@ -543,6 +596,7 @@ 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
|
||||||
@@ -627,6 +681,7 @@ 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
|
||||||
@@ -679,6 +734,7 @@ 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
|
||||||
@@ -690,6 +746,7 @@ 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
|
||||||
@@ -893,6 +950,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ 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
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ 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,
|
||||||
|
|||||||
@@ -979,6 +979,31 @@ 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 = "",
|
||||||
@@ -1695,6 +1720,91 @@ 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 = "",
|
||||||
@@ -1915,6 +2025,32 @@ 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 = "",
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ 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> };
|
||||||
|
|||||||
@@ -247,6 +247,13 @@ 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: {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ 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: {
|
||||||
@@ -406,6 +414,188 @@ 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();
|
||||||
|
|
||||||
|
|||||||
+134
-25
@@ -29,6 +29,8 @@ 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 {
|
||||||
@@ -119,6 +121,15 @@ 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;
|
||||||
@@ -130,6 +141,8 @@ 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>();
|
||||||
@@ -166,6 +179,7 @@ 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 };
|
||||||
@@ -175,6 +189,7 @@ 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);
|
||||||
@@ -335,7 +350,7 @@ export class AnkiIntegration {
|
|||||||
options,
|
options,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
showOsdNotification: (text: string) => this.showOsdNotification(text),
|
showOsdNotification: (text: string) => this.showStatusNotification(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),
|
||||||
@@ -387,7 +402,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.showOsdNotification(text),
|
showOsdNotification: (text: string) => this.showStatusNotification(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[],
|
||||||
@@ -463,7 +478,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.showOsdNotification(message),
|
showOsdNotification: (message) => this.showStatusNotification(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)),
|
||||||
@@ -510,7 +525,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.showOsdNotification(message),
|
showOsdNotification: (message) => this.showStatusNotification(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),
|
||||||
@@ -525,6 +540,10 @@ 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
|
||||||
@@ -860,10 +879,13 @@ export class AnkiIntegration {
|
|||||||
|
|
||||||
private showStatusNotification(message: string): void {
|
private showStatusNotification(message: string): void {
|
||||||
showStatusNotification(message, {
|
showStatusNotification(message, {
|
||||||
getNotificationType: () => this.config.behavior?.notificationType,
|
getNotificationType: () => this.getNotificationType(),
|
||||||
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);
|
||||||
@@ -872,19 +894,51 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -894,6 +948,23 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -1017,24 +1088,61 @@ export class AnkiIntegration {
|
|||||||
? `Updated card: ${label} (${errorSuffix})`
|
? `Updated card: ${label} (${errorSuffix})`
|
||||||
: `Updated card: ${label}`;
|
: `Updated card: ${label}`;
|
||||||
|
|
||||||
const type = this.config.behavior?.notificationType || 'osd';
|
const type = this.getNotificationType();
|
||||||
|
|
||||||
if (type === 'osd' || type === 'both') {
|
if (type === 'osd' || type === 'osd-system') {
|
||||||
this.showUpdateResult(message, errorSuffix === undefined);
|
this.showUpdateResult(message, errorSuffix === undefined);
|
||||||
} else {
|
} else {
|
||||||
this.clearUpdateProgress();
|
this.clearUpdateProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((type === 'system' || type === 'both') && this.notificationCallback) {
|
const shouldShowOverlayNotification =
|
||||||
let notificationIconPath: string | undefined;
|
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null;
|
||||||
|
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(
|
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||||
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');
|
||||||
}
|
}
|
||||||
@@ -1043,25 +1151,26 @@ export class AnkiIntegration {
|
|||||||
timestamp,
|
timestamp,
|
||||||
);
|
);
|
||||||
if (iconBuffer && iconBuffer.length > 0) {
|
if (iconBuffer && iconBuffer.length > 0) {
|
||||||
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
|
const notificationIcon: NotificationIcon = {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.notificationCallback('Anki Card Updated', {
|
return undefined;
|
||||||
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,3 +271,28 @@ 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, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -511,7 +511,6 @@ 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,9 +1,10 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
showProgressTick,
|
showProgressTick,
|
||||||
|
showStatusNotification,
|
||||||
showUpdateResult,
|
showUpdateResult,
|
||||||
} from './ui-feedback';
|
} from './ui-feedback';
|
||||||
|
|
||||||
@@ -65,3 +66,57 @@ 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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NotificationOptions } from '../types/anki';
|
import type { NotificationOptions } from '../types/anki';
|
||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../types/notification';
|
||||||
|
|
||||||
export interface UiFeedbackState {
|
export interface UiFeedbackState {
|
||||||
progressDepth: number;
|
progressDepth: number;
|
||||||
@@ -13,8 +14,9 @@ export interface UiFeedbackResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UiFeedbackNotificationContext {
|
export interface UiFeedbackNotificationContext {
|
||||||
getNotificationType: () => string | undefined;
|
getNotificationType: () => NotificationType | 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +38,29 @@ 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 === 'osd' || type === 'both') {
|
if (type === 'none') {
|
||||||
|
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') {
|
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||||
context.showSystemNotification('SubMiner', { body: message });
|
context.showSystemNotification('SubMiner', { body: message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ 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');
|
||||||
@@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => {
|
|||||||
"updates": {
|
"updates": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"checkIntervalHours": 6,
|
"checkIntervalHours": 6,
|
||||||
"notificationType": "both",
|
"notificationType": "osd-system",
|
||||||
"channel": "prerelease"
|
"channel": "prerelease"
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -182,7 +183,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, 'both');
|
assert.equal(validService.getConfig().updates.notificationType, 'osd-system');
|
||||||
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
||||||
|
|
||||||
const invalidDir = makeTempDir();
|
const invalidDir = makeTempDir();
|
||||||
@@ -212,6 +213,69 @@ 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');
|
||||||
@@ -2750,7 +2814,7 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
|
/"notificationType": "system",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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 } =
|
||||||
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
subsync,
|
subsync,
|
||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
|
notifications,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
subtitleSidebar,
|
subtitleSidebar,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 },
|
||||||
@@ -101,6 +102,7 @@ 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: [],
|
||||||
@@ -129,5 +131,8 @@ 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: 'osd',
|
notificationType: 'overlay',
|
||||||
autoUpdateNewCards: true,
|
autoUpdateNewCards: true,
|
||||||
},
|
},
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
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(
|
||||||
@@ -484,9 +489,11 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
{
|
{
|
||||||
path: 'updates.notificationType',
|
path: 'updates.notificationType',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
enumValues: ['system', 'osd', 'both', 'none'],
|
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||||
|
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||||
defaultValue: defaultConfig.updates.notificationType,
|
defaultValue: defaultConfig.updates.notificationType,
|
||||||
description: 'How SubMiner announces available updates.',
|
description:
|
||||||
|
'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',
|
||||||
@@ -495,6 +502,13 @@ 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',
|
||||||
@@ -608,5 +622,11 @@ 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,5 +1,9 @@
|
|||||||
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(
|
||||||
@@ -158,9 +162,11 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
{
|
{
|
||||||
path: 'ankiConnect.behavior.notificationType',
|
path: 'ankiConnect.behavior.notificationType',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
enumValues: ['osd', 'system', 'both', 'none'],
|
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||||
|
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||||
description: 'Notification surface used to announce mining and update outcomes.',
|
description:
|
||||||
|
'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',
|
||||||
|
|||||||
@@ -27,7 +27,17 @@ 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,6 +63,12 @@ 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.'],
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
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;
|
||||||
@@ -42,6 +47,8 @@ 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,
|
||||||
@@ -99,6 +106,22 @@ 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) {
|
||||||
@@ -289,8 +312,6 @@ 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) {
|
||||||
@@ -328,11 +349,6 @@ 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,
|
||||||
@@ -633,7 +649,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 'osd', 'system', 'both', or 'none'.",
|
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -194,19 +195,14 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notificationType = asString(src.updates.notificationType);
|
const notificationType = asString(src.updates.notificationType);
|
||||||
if (
|
if (isNotificationType(notificationType)) {
|
||||||
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 system, osd, both, or none.',
|
'Expected overlay, system, both, none, osd, or osd-system.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +236,7 @@ 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) {
|
||||||
@@ -323,4 +320,18 @@ 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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ 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]),
|
||||||
);
|
);
|
||||||
@@ -411,6 +412,9 @@ 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' };
|
||||||
}
|
}
|
||||||
@@ -478,6 +482,7 @@ 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',
|
||||||
@@ -577,6 +582,7 @@ 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'
|
||||||
@@ -686,6 +692,7 @@ 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')
|
||||||
@@ -709,7 +716,9 @@ 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?.enumValues ? { enumValues: option.enumValues } : {}),
|
...(option?.settingsEnumValues || 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.') ||
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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';
|
||||||
@@ -40,6 +41,7 @@ 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>;
|
||||||
@@ -103,6 +105,8 @@ 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);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ 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 = {
|
||||||
@@ -277,20 +281,80 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
|||||||
releaseWarmup();
|
releaseWarmup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const { deps } = makeDeps({
|
let releaseYomitan!: () => void;
|
||||||
startBackgroundWarmups: () => {
|
const yomitanGate = new Promise<void>((resolve) => {
|
||||||
calls.push('startBackgroundWarmups');
|
releaseYomitan = resolve;
|
||||||
},
|
|
||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
|
||||||
});
|
});
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||||
|
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||||
|
loadYomitanExtension: async () => {
|
||||||
|
calls.push('loadYomitanExtension:start');
|
||||||
|
await yomitanGate;
|
||||||
|
calls.push('loadYomitanExtension:done');
|
||||||
|
},
|
||||||
|
handleFirstRunSetup: async () => {
|
||||||
|
calls.push('handleFirstRunSetup');
|
||||||
|
},
|
||||||
|
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('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||||
|
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 () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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',
|
||||||
@@ -38,6 +39,9 @@ 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');
|
||||||
},
|
},
|
||||||
@@ -55,7 +59,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
hasRuntimeOptionsManager: () => true,
|
hasRuntimeOptionsManager: () => true,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return { options, calls, sentCommands, osd };
|
return { options, calls, sentCommands, osd, playbackFeedback };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||||
@@ -65,41 +69,53 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
|||||||
assert.deepEqual(osd, []);
|
assert.deepEqual(osd, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => {
|
test('handleMpvCommandFromIpc routes show-text through playback feedback', () => {
|
||||||
const { options, sentCommands, osd } = createOptions();
|
const { options, sentCommands, osd, playbackFeedback } = 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, ['Subtitle position: ${sub-pos}']);
|
assert.deepEqual(osd, []);
|
||||||
|
assert.deepEqual(playbackFeedback, ['Subtitle position: ${sub-pos}']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc emits resolved osd for primary subtitle track keybinding proxies', async () => {
|
test('handleMpvCommandFromIpc emits resolved feedback for primary subtitle track keybinding proxies', async () => {
|
||||||
const { options, sentCommands, osd } = createOptions({
|
const { options, sentCommands, osd, playbackFeedback } = 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, ['Subtitle track: Internal #3 - Japanese (active)']);
|
assert.deepEqual(osd, []);
|
||||||
|
assert.deepEqual(playbackFeedback, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc emits resolved osd for secondary subtitle track keybinding proxies', async () => {
|
test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle track keybinding proxies', async () => {
|
||||||
const { options, sentCommands, osd } = createOptions({
|
const { options, sentCommands, osd, playbackFeedback } = 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, ['Secondary subtitle track: External #8 - English Commentary']);
|
assert.deepEqual(osd, []);
|
||||||
|
assert.deepEqual(playbackFeedback, ['Secondary subtitle track: External #8 - English Commentary']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', async () => {
|
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
|
||||||
const { options, sentCommands, osd } = createOptions();
|
const { options, sentCommands, osd, playbackFeedback } = 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, ['Subtitle delay: ${sub-delay}']);
|
assert.deepEqual(osd, []);
|
||||||
|
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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>;
|
||||||
@@ -68,13 +69,14 @@ 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);
|
||||||
options.showMpvOsd(resolved || template);
|
showFeedback(resolved || template);
|
||||||
} catch {
|
} catch {
|
||||||
options.showMpvOsd(template);
|
showFeedback(template);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,6 +144,15 @@ 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();
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ 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 () => {},
|
||||||
@@ -242,6 +243,7 @@ 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: () => {},
|
||||||
@@ -552,6 +554,7 @@ 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: () => {},
|
||||||
@@ -977,6 +980,7 @@ 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) => {
|
||||||
@@ -1058,6 +1062,7 @@ 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) => {
|
||||||
@@ -1262,6 +1267,44 @@ 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(
|
||||||
@@ -1289,6 +1332,7 @@ 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,6 +53,11 @@ 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;
|
||||||
@@ -80,6 +85,7 @@ 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>;
|
||||||
@@ -223,6 +229,25 @@ 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;
|
||||||
@@ -242,6 +267,11 @@ 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;
|
||||||
@@ -262,6 +292,7 @@ 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>;
|
||||||
@@ -312,6 +343,7 @@ 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,
|
||||||
@@ -349,6 +381,7 @@ 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,
|
||||||
@@ -473,6 +506,22 @@ 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,
|
||||||
@@ -641,6 +690,10 @@ 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,6 +6,7 @@ import {
|
|||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuFieldGroupingRequestData,
|
KikuFieldGroupingRequestData,
|
||||||
|
OverlayNotificationPayload,
|
||||||
WindowGeometry,
|
WindowGeometry,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ 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>;
|
||||||
@@ -61,6 +63,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
|||||||
args.createFieldGroupingCallback(),
|
args.createFieldGroupingCallback(),
|
||||||
args.knownWordCacheStatePath,
|
args.knownWordCacheStatePath,
|
||||||
args.aiConfig,
|
args.aiConfig,
|
||||||
|
undefined,
|
||||||
|
args.showOverlayNotification,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ 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>;
|
||||||
@@ -156,6 +161,7 @@ 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>;
|
||||||
@@ -191,6 +197,7 @@ 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,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
|||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
openControllerDebug: null,
|
openControllerDebug: null,
|
||||||
toggleSubtitleSidebar: null,
|
toggleSubtitleSidebar: null,
|
||||||
|
toggleNotificationHistory: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
openControllerDebug: null,
|
openControllerDebug: null,
|
||||||
toggleSubtitleSidebar: null,
|
toggleSubtitleSidebar: null,
|
||||||
|
toggleNotificationHistory: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,127 @@ 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('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
|
test('macOS dismisses overlay loading OSD when tracker recovers', () => {
|
||||||
|
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 = {
|
||||||
@@ -197,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
|||||||
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', () => {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ 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;
|
||||||
@@ -310,8 +311,18 @@ 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.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
if (!args.showOverlayLoadingOsd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||||
@@ -320,6 +331,9 @@ 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 (
|
||||||
@@ -350,6 +364,7 @@ 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);
|
||||||
@@ -371,7 +386,15 @@ 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);
|
||||||
@@ -432,6 +455,7 @@ 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);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ 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;
|
||||||
@@ -139,6 +140,7 @@ 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,6 +25,7 @@ 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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
@@ -79,6 +80,9 @@ 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;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
openControllerDebug: null,
|
openControllerDebug: null,
|
||||||
toggleSubtitleSidebar: null,
|
toggleSubtitleSidebar: null,
|
||||||
|
toggleNotificationHistory: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
|
|||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
|
assert.deepEqual(
|
||||||
|
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'],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ 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[] {
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
await runAppReadyRuntime({
|
await runAppReadyRuntime({
|
||||||
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
|||||||
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('load-yomitan') < calls.indexOf('init-overlay'));
|
assert.ok(calls.indexOf('warmups') !== -1);
|
||||||
|
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 () => {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ 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 = [
|
||||||
@@ -229,6 +230,31 @@ 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();
|
||||||
@@ -247,7 +273,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
|
|
||||||
if (deps.shouldUseMinimalStartup?.()) {
|
if (deps.shouldUseMinimalStartup?.()) {
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
deps.handleInitialArgs();
|
handleInitialArgsOnce();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +282,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await ensureYomitanExtensionReady();
|
await ensureYomitanExtensionReady();
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
await deps.handleFirstRunSetup();
|
await handleFirstRunSetupOnce();
|
||||||
deps.handleInitialArgs();
|
handleInitialArgsOnce();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -279,8 +305,6 @@ 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();
|
||||||
@@ -326,16 +350,24 @@ 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 deps.handleFirstRunSetup();
|
await handleFirstRunSetupOnce();
|
||||||
deps.handleInitialArgs();
|
handleInitialArgsOnce();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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(
|
||||||
@@ -67,5 +68,6 @@ 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')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export interface ConfiguredWindowsMpvLaunch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWindowsMpvPluginRuntimeConfig(
|
export function buildWindowsMpvPluginRuntimeConfig(
|
||||||
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'>,
|
config: Pick<
|
||||||
|
ResolvedConfig,
|
||||||
|
'ankiConnect' | 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'
|
||||||
|
>,
|
||||||
): SubminerPluginRuntimeScriptOptConfig {
|
): SubminerPluginRuntimeScriptOptConfig {
|
||||||
return {
|
return {
|
||||||
socketPath: config.mpv.socketPath,
|
socketPath: config.mpv.socketPath,
|
||||||
@@ -27,6 +30,9 @@ 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,
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ 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,
|
||||||
@@ -381,6 +382,7 @@ 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',
|
||||||
|
|||||||
+426
-50
@@ -62,6 +62,7 @@ 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';
|
||||||
@@ -138,9 +139,13 @@ 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';
|
||||||
@@ -187,6 +192,7 @@ 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';
|
||||||
@@ -599,7 +605,21 @@ 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 { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
import {
|
||||||
|
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,
|
||||||
@@ -1232,7 +1252,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
|||||||
mainWindow.webContents.focus();
|
mainWindow.webContents.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text),
|
||||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||||
notifyPrimarySubtitleLoaded: () =>
|
notifyPrimarySubtitleLoaded: () =>
|
||||||
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||||
@@ -1295,7 +1315,6 @@ 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(),
|
||||||
@@ -1467,6 +1486,9 @@ 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,
|
||||||
@@ -1714,7 +1736,7 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
|||||||
setSubsyncInProgress: (inProgress) => {
|
setSubsyncInProgress: (inProgress) => {
|
||||||
appState.subsyncInProgress = inProgress;
|
appState.subsyncInProgress = inProgress;
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showSubsyncStatusNotification(text),
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
||||||
@@ -1736,7 +1758,10 @@ 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,
|
||||||
@@ -1767,11 +1792,21 @@ function isYoutubePlaybackActiveNow(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reportYoutubeSubtitleFailure(message: string): void {
|
function reportYoutubeSubtitleFailure(message: string): void {
|
||||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
const type = getConfiguredStatusNotificationType();
|
||||||
if (type === 'osd' || type === 'both') {
|
if (type === 'none') {
|
||||||
|
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') {
|
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||||
try {
|
try {
|
||||||
showDesktopNotification('SubMiner', { body: message });
|
showDesktopNotification('SubMiner', { body: message });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1782,13 +1817,22 @@ function reportYoutubeSubtitleFailure(message: string): void {
|
|||||||
|
|
||||||
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
||||||
if (youtubeFlowRuntime.hasActiveSession()) {
|
if (youtubeFlowRuntime.hasActiveSession()) {
|
||||||
showMpvOsd('YouTube subtitle flow already in progress.');
|
showConfiguredStatusNotification('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) {
|
||||||
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
|
showConfiguredStatusNotification(
|
||||||
|
'YouTube subtitle picker is only available during YouTube playback.',
|
||||||
|
{
|
||||||
|
title: 'YouTube subtitles',
|
||||||
|
variant: 'warning',
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await youtubeFlowRuntime.openManualPicker({
|
await youtubeFlowRuntime.openManualPicker({
|
||||||
@@ -1861,10 +1905,16 @@ 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;
|
||||||
}
|
}
|
||||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
if (!appState.currentSubText.trim()) {
|
||||||
|
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '__warm__', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||||
@@ -1897,6 +1947,8 @@ 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;
|
||||||
@@ -1971,6 +2023,7 @@ 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) {
|
||||||
@@ -1986,6 +2039,38 @@ 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[],
|
||||||
@@ -2134,7 +2219,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
|||||||
|
|
||||||
return windowTracker.isTargetWindowFocused();
|
return windowTracker.isTargetWindowFocused();
|
||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||||
openRuntimeOptionsPalette: () => {
|
openRuntimeOptionsPalette: () => {
|
||||||
openRuntimeOptionsPalette();
|
openRuntimeOptionsPalette();
|
||||||
},
|
},
|
||||||
@@ -2177,7 +2262,9 @@ 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),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2533,8 +2620,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
onSyncStatus: (event) => {
|
onSyncStatus: (event) => {
|
||||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||||
showOsd: (message) => showMpvOsd(message),
|
showOsd: (message) => showMpvOsd(message),
|
||||||
|
showOverlayNotification,
|
||||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||||
startupOsdSequencer,
|
startupOsdSequencer,
|
||||||
});
|
});
|
||||||
@@ -2611,7 +2699,10 @@ 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) => {
|
||||||
showMpvOsd(message);
|
showOverlayLoadingStatusNotification(message);
|
||||||
|
},
|
||||||
|
dismissOverlayLoadingOsd: () => {
|
||||||
|
dismissOverlayLoadingStatusNotification();
|
||||||
},
|
},
|
||||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||||
@@ -2640,6 +2731,7 @@ 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>> = [];
|
||||||
@@ -2654,6 +2746,8 @@ 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.
|
||||||
@@ -2676,6 +2770,7 @@ 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();
|
||||||
@@ -3148,6 +3243,23 @@ 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;
|
||||||
@@ -3186,6 +3298,28 @@ 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(),
|
||||||
@@ -3246,7 +3380,8 @@ const linuxOverlayPointerInteractionDeps = {
|
|||||||
getCursorScreenPoint: () =>
|
getCursorScreenPoint: () =>
|
||||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
getRendererInteractiveHint: () =>
|
||||||
|
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||||
@@ -3293,6 +3428,177 @@ 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,
|
||||||
@@ -3383,12 +3689,12 @@ function openOverlayHostedModalWithOsd(
|
|||||||
void openModal(createOverlayHostedModalOpenDeps())
|
void openModal(createOverlayHostedModalOpenDeps())
|
||||||
.then((opened) => {
|
.then((opened) => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
showMpvOsd(unavailableMessage);
|
showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(failureLogMessage, error);
|
logger.error(failureLogMessage, error);
|
||||||
showMpvOsd(unavailableMessage);
|
showConfiguredStatusNotification(unavailableMessage, { variant: 'error' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3419,7 +3725,7 @@ function openSessionHelpOverlay(): void {
|
|||||||
function openCharacterDictionaryManagerOverlay(): void {
|
function openCharacterDictionaryManagerOverlay(): void {
|
||||||
openCharacterDictionaryManagerWithConfigGate({
|
openCharacterDictionaryManagerWithConfigGate({
|
||||||
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||||
openManager: () => {
|
openManager: () => {
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
openCharacterDictionaryManagerModalRuntime,
|
openCharacterDictionaryManagerModalRuntime,
|
||||||
@@ -3428,6 +3734,7 @@ 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),
|
||||||
});
|
});
|
||||||
@@ -3451,7 +3758,10 @@ function openControllerDebugOverlay(): void {
|
|||||||
|
|
||||||
function openPlaylistBrowser(): void {
|
function openPlaylistBrowser(): void {
|
||||||
if (!appState.mpvClient?.connected) {
|
if (!appState.mpvClient?.connected) {
|
||||||
showMpvOsd('Playlist browser requires active playback.');
|
showConfiguredStatusNotification('Playlist browser requires active playback.', {
|
||||||
|
title: 'Playlist browser',
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
@@ -3633,7 +3943,7 @@ const {
|
|||||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
showMpvOsd(text);
|
showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||||
},
|
},
|
||||||
updateCurrentMediaTitle: (title) => {
|
updateCurrentMediaTitle: (title) => {
|
||||||
mediaRuntime.updateCurrentMediaTitle(title);
|
mediaRuntime.updateCurrentMediaTitle(title);
|
||||||
@@ -3767,7 +4077,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) => showMpvOsd(message),
|
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||||
clearSetupWindow: () => {
|
clearSetupWindow: () => {
|
||||||
appState.jellyfinSetupWindow = null;
|
appState.jellyfinSetupWindow = null;
|
||||||
},
|
},
|
||||||
@@ -3935,8 +4245,10 @@ const {
|
|||||||
registerSubminerProtocolClient,
|
registerSubminerProtocolClient,
|
||||||
} = composeAnilistSetupHandlers({
|
} = composeAnilistSetupHandlers({
|
||||||
notifyDeps: {
|
notifyDeps: {
|
||||||
|
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||||
hasMpvClient: () => Boolean(appState.mpvClient),
|
hasMpvClient: () => Boolean(appState.mpvClient),
|
||||||
showMpvOsd: (message) => showMpvOsd(message),
|
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||||
|
showOverlayNotification,
|
||||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
},
|
},
|
||||||
@@ -4263,7 +4575,7 @@ const {
|
|||||||
rememberAttemptedUpdateKey: (key) => {
|
rememberAttemptedUpdateKey: (key) => {
|
||||||
rememberAnilistAttemptedUpdate(key);
|
rememberAnilistAttemptedUpdate(key);
|
||||||
},
|
},
|
||||||
showMpvOsd: (message) => showMpvOsd(message),
|
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||||
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,
|
||||||
@@ -4936,6 +5248,8 @@ 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();
|
||||||
},
|
},
|
||||||
@@ -5014,7 +5328,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined)
|
|||||||
const {
|
const {
|
||||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||||
tokenizeSubtitle,
|
tokenizeSubtitle: tokenizeSubtitleRuntime,
|
||||||
createMecabTokenizerAndCheck,
|
createMecabTokenizerAndCheck,
|
||||||
prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups,
|
startBackgroundWarmups,
|
||||||
@@ -5037,6 +5351,7 @@ const {
|
|||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
},
|
},
|
||||||
onMpvConnected: () => {
|
onMpvConnected: () => {
|
||||||
|
maybeStartOverlayLoadingOsd();
|
||||||
if (appState.sessionBindingsInitialized) {
|
if (appState.sessionBindingsInitialized) {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, [
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
'script-message',
|
'script-message',
|
||||||
@@ -5074,6 +5389,7 @@ 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,
|
||||||
@@ -5329,13 +5645,13 @@ const {
|
|||||||
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
|
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
|
||||||
ensureFrequencyDictionaryLookup: () =>
|
ensureFrequencyDictionaryLookup: () =>
|
||||||
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
||||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
showMpvOsd: (message: string) => showConfiguredStatusNotification(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 = getResolvedConfig().ankiConnect.behavior.notificationType;
|
const type = getConfiguredStatusNotificationType();
|
||||||
return type === 'osd' || type === 'both';
|
return type === 'osd' || type === 'osd-system';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5388,6 +5704,14 @@ 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 () => {
|
||||||
@@ -5859,8 +6183,7 @@ 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.',
|
||||||
);
|
);
|
||||||
showDesktopNotification('SubMiner', { body: message });
|
showConfiguredStatusNotification(message, { variant: 'warning' });
|
||||||
showMpvOsd(message);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
openYomitanSettingsHandler();
|
openYomitanSettingsHandler();
|
||||||
@@ -5947,7 +6270,7 @@ const {
|
|||||||
},
|
},
|
||||||
numericShortcutRuntimeMainDeps: {
|
numericShortcutRuntimeMainDeps: {
|
||||||
globalShortcut,
|
globalShortcut,
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||||
clearTimer: (timer) => clearTimeout(timer),
|
clearTimer: (timer) => clearTimeout(timer),
|
||||||
},
|
},
|
||||||
@@ -6182,6 +6505,7 @@ 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);
|
||||||
},
|
},
|
||||||
@@ -6206,7 +6530,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
|||||||
broadcastToOverlayWindows: (channel, mode) => {
|
broadcastToOverlayWindows: (channel, mode) => {
|
||||||
broadcastToOverlayWindows(channel, mode);
|
broadcastToOverlayWindows(channel, mode);
|
||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
},
|
},
|
||||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||||
});
|
});
|
||||||
@@ -6243,7 +6567,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler =
|
|||||||
createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
||||||
getAnkiIntegration: () => appState.ankiIntegration,
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
readClipboardText: () => clipboard.readText(),
|
readClipboardText: () => clipboard.readText(),
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
updateLastCardFromClipboardCore,
|
updateLastCardFromClipboardCore,
|
||||||
});
|
});
|
||||||
const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler();
|
const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler();
|
||||||
@@ -6262,7 +6586,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler(
|
|||||||
|
|
||||||
const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({
|
const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({
|
||||||
getAnkiIntegration: () => appState.ankiIntegration,
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
triggerFieldGroupingCore,
|
triggerFieldGroupingCore,
|
||||||
});
|
});
|
||||||
const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler();
|
const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler();
|
||||||
@@ -6271,7 +6595,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie
|
|||||||
const buildMarkLastCardAsAudioCardMainDepsHandler =
|
const buildMarkLastCardAsAudioCardMainDepsHandler =
|
||||||
createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
||||||
getAnkiIntegration: () => appState.ankiIntegration,
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
markLastCardAsAudioCardCore,
|
markLastCardAsAudioCardCore,
|
||||||
});
|
});
|
||||||
const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler();
|
const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler();
|
||||||
@@ -6282,7 +6606,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
|
|||||||
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
|
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
|
||||||
getAnkiIntegration: () => appState.ankiIntegration,
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
mineSentenceCardCore,
|
mineSentenceCardCore,
|
||||||
recordCardsMined: (count, noteIds) => {
|
recordCardsMined: (count, noteIds) => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
@@ -6296,7 +6620,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) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||||
handleMultiCopyDigitCore,
|
handleMultiCopyDigitCore,
|
||||||
});
|
});
|
||||||
const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler();
|
const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler();
|
||||||
@@ -6305,7 +6629,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) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
copyCurrentSubtitleCore,
|
copyCurrentSubtitleCore,
|
||||||
});
|
});
|
||||||
const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler();
|
const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler();
|
||||||
@@ -6316,7 +6640,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) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
logError: (message, err) => {
|
logError: (message, err) => {
|
||||||
logger.error(message, err);
|
logger.error(message, err);
|
||||||
},
|
},
|
||||||
@@ -6359,7 +6683,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler =
|
|||||||
appendClipboardVideoToQueueRuntime,
|
appendClipboardVideoToQueueRuntime,
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
readClipboardText: () => clipboard.readText(),
|
readClipboardText: () => clipboard.readText(),
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||||
sendMpvCommand: (command) => {
|
sendMpvCommand: (command) => {
|
||||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
},
|
},
|
||||||
@@ -6498,7 +6822,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
logger.warn('Failed to save Jellyfin subtitle delay.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||||
@@ -6524,6 +6848,7 @@ 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();
|
||||||
@@ -6555,12 +6880,12 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
}
|
}
|
||||||
return applyRuntimeOptionResultRuntime(
|
return applyRuntimeOptionResultRuntime(
|
||||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||||
(text) => showMpvOsd(text),
|
(text) => showConfiguredPlaybackFeedback(text),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
playNextPlaylistItem: () =>
|
playNextPlaylistItem: () =>
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6582,10 +6907,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
}
|
}
|
||||||
return applyRuntimeOptionResultRuntime(
|
return applyRuntimeOptionResultRuntime(
|
||||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||||
(text) => showMpvOsd(text),
|
(text) => showConfiguredPlaybackFeedback(text),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showConfiguredStatusNotification(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) =>
|
||||||
@@ -6601,7 +6927,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
registration: {
|
registration: {
|
||||||
runtimeOptions: {
|
runtimeOptions: {
|
||||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
},
|
},
|
||||||
mainDeps: {
|
mainDeps: {
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
@@ -6671,6 +6997,30 @@ 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),
|
||||||
@@ -6682,9 +7032,14 @@ 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,
|
||||||
@@ -6808,6 +7163,7 @@ 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();
|
||||||
@@ -6830,7 +7186,9 @@ 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(),
|
||||||
@@ -6938,6 +7296,7 @@ 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(),
|
||||||
@@ -6974,7 +7333,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) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||||
@@ -7199,11 +7558,14 @@ 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();
|
||||||
if (appState.currentSubText.trim()) {
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
|
||||||
}
|
|
||||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||||
},
|
},
|
||||||
onWindowClosed: (windowKind, window) => {
|
onWindowClosed: (windowKind, window) => {
|
||||||
@@ -7242,7 +7604,8 @@ 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),
|
showMpvOsd: (message: string) =>
|
||||||
|
showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7389,6 +7752,7 @@ 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: () =>
|
||||||
@@ -7480,11 +7844,15 @@ 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();
|
||||||
@@ -7498,10 +7866,14 @@ 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();
|
||||||
@@ -7512,11 +7884,15 @@ 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();
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ 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(
|
||||||
@@ -133,6 +134,8 @@ export function createAppReadyRuntimeDeps(
|
|||||||
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||||
|
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
|
||||||
|
params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -59,6 +60,7 @@ 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'];
|
||||||
@@ -82,6 +84,7 @@ 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'];
|
||||||
@@ -124,6 +127,7 @@ 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'];
|
||||||
@@ -221,6 +225,7 @@ 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'];
|
||||||
@@ -240,6 +245,7 @@ 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,
|
||||||
@@ -261,6 +267,7 @@ 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,
|
||||||
@@ -309,6 +316,7 @@ 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,
|
||||||
@@ -414,6 +422,7 @@ 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,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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>;
|
||||||
@@ -41,6 +42,7 @@ 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) =>
|
||||||
|
|||||||
+163
-12
@@ -59,6 +59,50 @@ 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(
|
||||||
@@ -68,7 +112,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+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,15 +133,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+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
/if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
setOverlayBlock,
|
setOverlayBlock,
|
||||||
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +162,23 @@ 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(
|
||||||
@@ -160,7 +221,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
|
test('startup autoplay release is tied to 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\}\);/,
|
||||||
@@ -171,7 +232,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
|||||||
|
|
||||||
assert.ok(gateBlock);
|
assert.ok(gateBlock);
|
||||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||||
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
|
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||||
|
|
||||||
@@ -180,6 +241,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
|||||||
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(
|
||||||
@@ -189,10 +281,15 @@ 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', () => {
|
||||||
@@ -216,11 +313,14 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
|
|||||||
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
|
test('warm tokenization release can signal readiness before the first subtitle appears', () => {
|
||||||
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;
|
||||||
@@ -230,7 +330,12 @@ test('warm tokenization release reuses current subtitle payload instead of synth
|
|||||||
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/);
|
||||||
@@ -247,7 +352,10 @@ test('stats server Yomitan note creation honors configured Anki server override
|
|||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(addYomitanNoteBlock);
|
assert.ok(addYomitanNoteBlock);
|
||||||
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
assert.match(
|
||||||
|
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/);
|
||||||
});
|
});
|
||||||
@@ -321,6 +429,49 @@ 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(
|
||||||
@@ -334,11 +485,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+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,7 +508,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+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,7 @@ 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,
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
|
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when 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 does not live-update after ret
|
|||||||
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', 'inflight:false']);
|
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -194,8 +194,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.processNextAnilistRetryUpdate();
|
const retryResult = await deps.processNextAnilistRetryUpdate();
|
||||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||||
|
if (retryResult.ok) {
|
||||||
|
deps.showMpvOsd(retryResult.message);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ 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,8 +18,12 @@ 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,6 +19,24 @@ 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,3 +1,5 @@
|
|||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
export type ConsumeAnilistSetupTokenDeps = {
|
export type ConsumeAnilistSetupTokenDeps = {
|
||||||
consumeAnilistSetupCallbackUrl: (input: {
|
consumeAnilistSetupCallbackUrl: (input: {
|
||||||
rawUrl: string;
|
rawUrl: string;
|
||||||
@@ -30,12 +32,35 @@ 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,6 +48,7 @@ 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'),
|
||||||
@@ -64,6 +65,7 @@ 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();
|
||||||
|
|||||||
@@ -45,5 +45,7 @@ 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,6 +314,100 @@ 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;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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>;
|
||||||
@@ -34,12 +37,22 @@ 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 =>
|
||||||
@@ -52,18 +65,43 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
pendingAutoplayReadySignal = null;
|
pendingAutoplayReadySignal = null;
|
||||||
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
||||||
autoPlayReadySignalGeneration += 1;
|
autoPlayReadySignalGeneration += 1;
|
||||||
|
invalidatePendingAutoplayReadyRetry();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => {
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
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 => {
|
||||||
@@ -139,6 +177,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pendingAutoplayReadySignal = null;
|
pendingAutoplayReadySignal = null;
|
||||||
|
invalidatePendingAutoplayReadyRetry();
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
deps.signalPluginAutoplayReady();
|
deps.signalPluginAutoplayReady();
|
||||||
@@ -152,10 +191,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isSignalTargetReady(signal)) {
|
if (!isSignalTargetReady(signal)) {
|
||||||
setPendingAutoplayReadySignal(signal);
|
const pendingSignalChanged = 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,6 +4,7 @@ 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'],
|
||||||
@@ -70,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto sync notifications never send desktop notifications', () => {
|
test('auto sync notifications send overlay and desktop delivery for both', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||||
@@ -80,14 +81,10 @@ test('auto sync notifications never send desktop notifications', () => {
|
|||||||
},
|
},
|
||||||
showDesktopNotification: (title, options) =>
|
showDesktopNotification: (title, options) =>
|
||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
showOverlayNotification: (payload) =>
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
calls.push(
|
||||||
getNotificationType: () => 'both',
|
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
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',
|
||||||
@@ -96,9 +93,25 @@ test('auto sync notifications never send desktop notifications', () => {
|
|||||||
},
|
},
|
||||||
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'), {
|
|
||||||
getNotificationType: () => 'both',
|
assert.deepEqual(calls, [
|
||||||
|
'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}`);
|
||||||
},
|
},
|
||||||
@@ -106,14 +119,30 @@ test('auto sync notifications never send desktop notifications', () => {
|
|||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
|
assert.deepEqual(calls, ['desktop:SubMiner:building']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => {
|
test('auto sync notifications keep osd-system on legacy surfaces', () => {
|
||||||
|
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: () => 'both',
|
getNotificationType: () => 'osd-system',
|
||||||
showOsd: (message) => {
|
showOsd: (message) => {
|
||||||
calls.push(`osd:${message}`);
|
calls.push(`osd:${message}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -122,7 +151,7 @@ test('auto sync notifications fall back to desktop for long progress when osd is
|
|||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
});
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||||
getNotificationType: () => 'both',
|
getNotificationType: () => 'osd-system',
|
||||||
showOsd: (message) => {
|
showOsd: (message) => {
|
||||||
calls.push(`osd:${message}`);
|
calls.push(`osd:${message}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -131,14 +160,19 @@ test('auto sync notifications fall back to desktop for long progress when osd is
|
|||||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']);
|
assert.deepEqual(calls, [
|
||||||
|
'osd:generating',
|
||||||
|
'desktop:SubMiner:generating',
|
||||||
|
'osd:ready',
|
||||||
|
'desktop:SubMiner:ready',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => {
|
test('auto sync notifications send osd-system desktop updates with startup sequencer', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||||
getNotificationType: () => 'both',
|
getNotificationType: () => 'osd-system',
|
||||||
showOsd: (message) => {
|
showOsd: (message) => {
|
||||||
calls.push(`osd:${message}`);
|
calls.push(`osd:${message}`);
|
||||||
},
|
},
|
||||||
@@ -154,3 +188,29 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot
|
|||||||
|
|
||||||
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,11 +1,14 @@
|
|||||||
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: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
getNotificationType: () => NotificationType | 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: (
|
||||||
@@ -14,39 +17,58 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
|
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
|
||||||
return type !== 'none';
|
return phase === 'ready' || phase === 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackToDesktop(
|
function overlayVariantForPhase(
|
||||||
type: 'osd' | 'system' | 'both' | 'none' | undefined,
|
|
||||||
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||||
): boolean {
|
): OverlayNotificationPayload['variant'] {
|
||||||
return (
|
if (phase === 'ready') return 'success';
|
||||||
(type === 'system' || type === 'both') &&
|
if (phase === 'failed') return 'error';
|
||||||
(phase === 'generating' || phase === 'building' || phase === 'importing')
|
return 'progress';
|
||||||
);
|
}
|
||||||
|
|
||||||
|
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();
|
const type = deps.getNotificationType() ?? 'overlay';
|
||||||
|
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) {
|
||||||
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||||
phase: event.phase,
|
phase: event.phase,
|
||||||
message: event.message,
|
message: event.message,
|
||||||
});
|
});
|
||||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
} else {
|
||||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
deps.showOsd(event.message);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const shown = deps.showOsd(event.message) !== false;
|
|
||||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
if (shouldShowDesktop(type) && !startupSequencerShown) {
|
||||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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}`),
|
||||||
@@ -39,6 +41,13 @@ 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,4 +1,6 @@
|
|||||||
export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
|
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.';
|
||||||
@@ -8,16 +10,27 @@ 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 === 'osd' || type === 'both') {
|
if (type === 'none') {
|
||||||
|
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') {
|
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||||
try {
|
try {
|
||||||
deps.showDesktopNotification('SubMiner', {
|
deps.showDesktopNotification('SubMiner', {
|
||||||
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
|
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ 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,6 +265,23 @@ 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']),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
|||||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||||
import type { AnkiConnectConfig } from '../../types/anki';
|
import type { AnkiConnectConfig } from '../../types/anki';
|
||||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
type ConfigHotReloadAppliedDeps = {
|
type ConfigHotReloadAppliedDeps = {
|
||||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||||
@@ -25,7 +26,9 @@ type ConfigHotReloadAppliedDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ConfigHotReloadMessageDeps = {
|
type ConfigHotReloadMessageDeps = {
|
||||||
|
getNotificationType?: () => NotificationType | undefined;
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,8 +181,23 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
|||||||
|
|
||||||
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
|
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
|
||||||
return (message: string): void => {
|
return (message: string): void => {
|
||||||
|
const type = deps.getNotificationType?.() ?? 'osd-system';
|
||||||
|
if (type === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'overlay' || type === 'both') {
|
||||||
|
deps.showOverlayNotification?.({
|
||||||
|
title: 'SubMiner',
|
||||||
|
body: message,
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'osd' || type === 'osd-system') {
|
||||||
deps.showMpvOsd(message);
|
deps.showMpvOsd(message);
|
||||||
|
}
|
||||||
|
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||||
deps.showDesktopNotification('SubMiner', { body: message });
|
deps.showDesktopNotification('SubMiner', { body: message });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
|||||||
deps: ConfigHotReloadMessageMainDeps,
|
deps: ConfigHotReloadMessageMainDeps,
|
||||||
) {
|
) {
|
||||||
return (): ConfigHotReloadMessageMainDeps => ({
|
return (): ConfigHotReloadMessageMainDeps => ({
|
||||||
|
getNotificationType: () => deps.getNotificationType?.(),
|
||||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||||
|
showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload),
|
||||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||||
deps.showDesktopNotification(title, options),
|
deps.showDesktopNotification(title, options),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
notifyConfiguredStatus,
|
||||||
|
} from './configured-status-notification';
|
||||||
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Subsync: choose engine and source', {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(
|
||||||
|
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
|
||||||
|
'desktop:SubMiner:Subsync: choose engine and source',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
isOverlayReady: () => false,
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
|
getNotificationType: () => 'overlay',
|
||||||
|
isOverlayReady: () => false,
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
|
getNotificationType: () => 'system',
|
||||||
|
isOverlayReady: () => false,
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Overlay loading...', {
|
||||||
|
getNotificationType: () => 'osd-system',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Subsync: syncing |',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(
|
||||||
|
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subsync-status',
|
||||||
|
title: 'Subsync',
|
||||||
|
variant: 'progress',
|
||||||
|
persistent: true,
|
||||||
|
desktop: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Primary subtitle: hover',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
{ delivery: 'feedback' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['overlay:SubMiner:Primary subtitle: hover']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus routes osd-system feedback through osd only', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Secondary subtitle: visible',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'osd-system',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
{ delivery: 'feedback' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['osd:Secondary subtitle: visible']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus suppresses system-only feedback', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Primary subtitle: visible',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'system',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
{ delivery: 'feedback' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playback feedback options reuse subtitle mode notification ids', () => {
|
||||||
|
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Primary subtitle: hover'), {
|
||||||
|
id: 'primary-subtitle-mode-feedback',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle: hidden'), {
|
||||||
|
id: 'secondary-subtitle-mode-feedback',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyConfiguredStatus('Overlay unavailable.', {
|
||||||
|
getNotificationType: () => 'overlay',
|
||||||
|
showOsd: (message) => {
|
||||||
|
calls.push(`osd:${message}`);
|
||||||
|
},
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery queues until an overlay window is ready', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||||
|
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
|
||||||
|
|
||||||
|
assert.equal(delivery.getQueuedCount(), 2);
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.equal(delivery.getQueuedCount(), 0);
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
'startup-tokenization:Loading',
|
||||||
|
'character-dictionary-auto-sync:Building',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery upserts queued progress by notification id', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
|
||||||
|
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
|
||||||
|
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery preserves queued events with distinct history ids', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) =>
|
||||||
|
sent.push(
|
||||||
|
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({
|
||||||
|
id: 'character-dictionary-auto-sync',
|
||||||
|
historyId: 'character-dictionary-auto-sync-checking',
|
||||||
|
title: 'Character dictionary',
|
||||||
|
body: 'Checking character dictionary...',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
delivery.send({
|
||||||
|
id: 'character-dictionary-auto-sync',
|
||||||
|
historyId: 'character-dictionary-auto-sync-building',
|
||||||
|
title: 'Character dictionary',
|
||||||
|
body: 'Building character dictionary...',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
|
||||||
|
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) =>
|
||||||
|
sent.push(
|
||||||
|
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
scheduleFlushRetry: (callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({
|
||||||
|
id: 'startup-tokenization',
|
||||||
|
title: 'Subtitle tokenization',
|
||||||
|
body: 'Loading subtitle tokenization...',
|
||||||
|
variant: 'progress',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
delivery.send({
|
||||||
|
id: 'startup-tokenization',
|
||||||
|
title: 'Subtitle tokenization',
|
||||||
|
body: 'Subtitle tokenization ready',
|
||||||
|
variant: 'success',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
scheduled.shift()?.();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
'startup-tokenization:Loading subtitle tokenization...:pin',
|
||||||
|
'startup-tokenization:Subtitle tokenization ready:auto',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
const delays: number[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) =>
|
||||||
|
sent.push(
|
||||||
|
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
scheduleFlushRetry: (callback, delayMs) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
delays.push(delayMs);
|
||||||
|
},
|
||||||
|
terminalUpdateDelayMs: 750,
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({
|
||||||
|
id: 'startup-subtitle-annotations',
|
||||||
|
title: 'Subtitle annotations',
|
||||||
|
body: 'Loading subtitle annotations |',
|
||||||
|
variant: 'progress',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
delivery.send({
|
||||||
|
id: 'startup-subtitle-annotations',
|
||||||
|
title: 'Subtitle annotations',
|
||||||
|
body: 'Subtitle annotations loaded',
|
||||||
|
variant: 'success',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
|
||||||
|
assert.equal(delivery.getQueuedCount(), 1);
|
||||||
|
assert.deepEqual(delays, [750]);
|
||||||
|
|
||||||
|
scheduled.shift()?.();
|
||||||
|
|
||||||
|
assert.equal(delivery.getQueuedCount(), 0);
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
|
||||||
|
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const scheduled: Array<() => void> = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||||
|
scheduleFlushRetry: (callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.equal(delivery.getQueuedCount(), 1);
|
||||||
|
assert.equal(scheduled.length, 1);
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
scheduled.shift()?.();
|
||||||
|
|
||||||
|
assert.equal(delivery.getQueuedCount(), 0);
|
||||||
|
assert.deepEqual(sent, ['startup-tokenization:Loading']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) =>
|
||||||
|
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||||
|
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const delivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => ready,
|
||||||
|
send: (payload) =>
|
||||||
|
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||||
|
|
||||||
|
ready = true;
|
||||||
|
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||||
|
delivery.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
|
||||||
|
|
||||||
|
export interface ConfiguredStatusNotificationDeps {
|
||||||
|
getNotificationType: () => NotificationType | undefined;
|
||||||
|
isOverlayReady?: () => boolean;
|
||||||
|
showOsd: (message: string) => boolean | void;
|
||||||
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfiguredStatusNotificationOptions {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
variant?: OverlayNotificationPayload['variant'];
|
||||||
|
persistent?: boolean;
|
||||||
|
desktop?: boolean;
|
||||||
|
delivery?: 'notification' | 'feedback';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackFeedbackNotificationOptions(
|
||||||
|
message: string,
|
||||||
|
): ConfiguredStatusNotificationOptions {
|
||||||
|
if (/^Primary subtitle: (hidden|visible|hover)$/.test(message)) {
|
||||||
|
return { id: 'primary-subtitle-mode-feedback' };
|
||||||
|
}
|
||||||
|
if (/^Secondary subtitle: (hidden|visible|hover)$/.test(message)) {
|
||||||
|
return { id: 'secondary-subtitle-mode-feedback' };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyConfiguredStatus(
|
||||||
|
message: string,
|
||||||
|
deps: ConfiguredStatusNotificationDeps,
|
||||||
|
options: ConfiguredStatusNotificationOptions = {},
|
||||||
|
): void {
|
||||||
|
const type = deps.getNotificationType() ?? 'overlay';
|
||||||
|
const delivery = options.delivery ?? 'notification';
|
||||||
|
const showOverlay = shouldShowOverlay(type);
|
||||||
|
const showOsd = shouldShowOsd(type);
|
||||||
|
const desktopEnabled = delivery !== 'feedback' && options.desktop !== false;
|
||||||
|
|
||||||
|
if (type === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delivery === 'feedback' && !showOverlay && !showOsd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOverlay) {
|
||||||
|
const overlayReady = deps.isOverlayReady?.() ?? true;
|
||||||
|
if (deps.showOverlayNotification && overlayReady) {
|
||||||
|
deps.showOverlayNotification({
|
||||||
|
id: options.id,
|
||||||
|
title: options.title ?? 'SubMiner',
|
||||||
|
body: message,
|
||||||
|
variant: options.variant ?? 'info',
|
||||||
|
persistent: options.persistent ?? false,
|
||||||
|
});
|
||||||
|
} else if (desktopEnabled && !shouldShowDesktop(type)) {
|
||||||
|
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOsd) {
|
||||||
|
deps.showOsd(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktopEnabled && shouldShowDesktop(type)) {
|
||||||
|
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,40 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
|
|||||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => {
|
||||||
|
let tokenizerCalled = false;
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: 'まだキャッシュされていない字幕',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
tokenizeUncached: false,
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
tokenizerCalled = true;
|
||||||
|
return { text, tokens: [{ text: 'ま' } as never] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tokenizerCalled, false);
|
||||||
|
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||||
|
assert.equal(payload.startTime, 1);
|
||||||
|
assert.equal(payload.tokens, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
|
||||||
|
const resolvedPayloads: SubtitleData[] = [];
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: '起動字幕',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }),
|
||||||
|
onResolvedSubtitle: (resolved) => {
|
||||||
|
resolvedPayloads.push(resolved);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolvedPayloads, [payload]);
|
||||||
|
});
|
||||||
|
|
||||||
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
|
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -84,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show
|
|||||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await primeVisibleOverlaySubtitleFromMpv({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
calls.push(`request:${name}`);
|
||||||
|
return '国内外から';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||||
|
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
|
deferUncachedRefresh: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']);
|
||||||
|
});
|
||||||
|
|
||||||
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
||||||
|
|||||||
@@ -10,24 +10,34 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
|||||||
currentSubtitleData: SubtitleData | null;
|
currentSubtitleData: SubtitleData | null;
|
||||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||||
|
tokenizeUncached?: boolean;
|
||||||
|
onResolvedSubtitle?: (payload: SubtitleData) => void;
|
||||||
}): Promise<SubtitleData> {
|
}): Promise<SubtitleData> {
|
||||||
|
const resolve = (payload: SubtitleData): SubtitleData => {
|
||||||
|
const timedPayload = deps.withCurrentSubtitleTiming(payload);
|
||||||
|
deps.onResolvedSubtitle?.(timedPayload);
|
||||||
|
return timedPayload;
|
||||||
|
};
|
||||||
|
|
||||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
return resolve(deps.currentSubtitleData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deps.currentSubText.trim()) {
|
if (!deps.currentSubText.trim()) {
|
||||||
return deps.withCurrentSubtitleTiming({
|
return resolve({
|
||||||
text: deps.currentSubText,
|
text: deps.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deps.tokenizeUncached !== false) {
|
||||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||||
if (tokenized) {
|
if (tokenized) {
|
||||||
return deps.withCurrentSubtitleTiming(tokenized);
|
return resolve(tokenized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deps.withCurrentSubtitleTiming({
|
return resolve({
|
||||||
text: deps.currentSubText,
|
text: deps.currentSubText,
|
||||||
tokens: null,
|
tokens: null,
|
||||||
});
|
});
|
||||||
@@ -41,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
|||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
refreshCurrentSubtitle: (text: string) => void;
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
emitSubtitle: (payload: SubtitleData) => void;
|
emitSubtitle: (payload: SubtitleData) => void;
|
||||||
|
deferUncachedRefresh?: boolean;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
emitSecondarySubtitle?: (text: string) => void;
|
emitSecondarySubtitle?: (text: string) => void;
|
||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
@@ -107,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deps.deferUncachedRefresh === true) {
|
||||||
|
await primeSecondarySubtitle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
deps.refreshCurrentSubtitle(text);
|
deps.refreshCurrentSubtitle(text);
|
||||||
await primeSecondarySubtitle();
|
await primeSecondarySubtitle();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
openControllerSelect: null,
|
openControllerSelect: null,
|
||||||
openControllerDebug: null,
|
openControllerDebug: null,
|
||||||
toggleSubtitleSidebar: null,
|
toggleSubtitleSidebar: null,
|
||||||
|
toggleNotificationHistory: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user