mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
feat(notifications): add overlay notifications with position config
- Add Catppuccin Macchiato overlay notification stack with 3s transient timeout - Add `notifications.overlayPosition` config (top-left | top | top-right) - Route startup tokenization and subtitle annotation status through configured surfaces - Deduplicate rapid subtitle mode toggle notifications - Change `both` to mean overlay + system; add `osd-system` as legacy alias for old behavior - Keep `osd`/`osd-system` as config-file-only legacy values; Settings UI offers overlay/system/both/none
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
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.
|
||||||
|
- Routed startup tokenization and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
+11
-2
@@ -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.
|
||||||
@@ -539,7 +548,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,13 @@ 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.
|
||||||
|
|
||||||
`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 +353,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,
|
||||||
|
|||||||
+78
-57
@@ -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
|
||||||
|
|
||||||
@@ -202,12 +203,32 @@ 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. |
|
||||||
|
|
||||||
|
`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"`. |
|
||||||
|
|
||||||
|
Startup tokenization and subtitle annotation status follows the configured notification surface. 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
|
||||||
|
|
||||||
@@ -943,57 +964,57 @@ 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) |
|
||||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `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`. |
|
||||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||||
|
|
||||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -539,7 +548,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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +344,7 @@ 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',
|
||||||
@@ -403,6 +407,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',
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -188,6 +208,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
|||||||
'subminer-auto_start=yes',
|
'subminer-auto_start=yes',
|
||||||
'subminer-auto_start_visible_overlay=no',
|
'subminer-auto_start_visible_overlay=no',
|
||||||
'subminer-auto_start_pause_until_ready=yes',
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'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 +226,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',
|
||||||
@@ -218,6 +240,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
|||||||
'subminer-auto_start=yes',
|
'subminer-auto_start=yes',
|
||||||
'subminer-auto_start_visible_overlay=no',
|
'subminer-auto_start_visible_overlay=no',
|
||||||
'subminer-auto_start_pause_until_ready=yes',
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'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,7 @@ export interface PluginRuntimeConfig {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: 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)
|
||||||
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
if opts.osd_messages then
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -56,7 +57,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()
|
||||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
if opts.osd_messages then
|
||||||
|
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()
|
||||||
|
|||||||
@@ -406,6 +406,40 @@ 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 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();
|
||||||
|
|
||||||
|
|||||||
+78
-8
@@ -29,6 +29,7 @@ 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 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 {
|
||||||
@@ -130,6 +131,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 +169,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 +179,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 +340,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 +392,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 +468,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 +515,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),
|
||||||
@@ -860,10 +865,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 +880,51 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getNotificationType(): NotificationType {
|
||||||
|
return this.config.behavior?.notificationType ?? 'overlay';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +934,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,15 +1074,28 @@ 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) {
|
if ((type === 'overlay' || type === 'both') && this.overlayNotificationCallback) {
|
||||||
|
this.overlayNotificationCallback({
|
||||||
|
id: 'anki-update-progress',
|
||||||
|
title: 'Anki Card Updated',
|
||||||
|
body: message,
|
||||||
|
variant: errorSuffix === undefined ? 'success' : 'error',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
||||||
|
this.notificationCallback
|
||||||
|
) {
|
||||||
let notificationIconPath: string | undefined;
|
let notificationIconPath: string | undefined;
|
||||||
|
|
||||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||||
|
|||||||
@@ -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,38 @@ 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 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() ?? 'overlay';
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,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 +182,7 @@ test('parses updates config and warns on invalid values', () => {
|
|||||||
const validService = new ConfigService(validDir);
|
const validService = new ConfigService(validDir);
|
||||||
assert.equal(validService.getConfig().updates.enabled, false);
|
assert.equal(validService.getConfig().updates.enabled, false);
|
||||||
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
||||||
assert.equal(validService.getConfig().updates.notificationType, '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 +212,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 +2813,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 },
|
||||||
@@ -129,5 +130,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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ConfigOptionRegistryEntry {
|
|||||||
defaultValue: unknown;
|
defaultValue: unknown;
|
||||||
description: string;
|
description: string;
|
||||||
enumValues?: readonly string[];
|
enumValues?: readonly string[];
|
||||||
|
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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,4 +319,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',
|
||||||
@@ -686,6 +691,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 +715,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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+185
-42
@@ -138,6 +138,9 @@ import type {
|
|||||||
SubtitleData,
|
SubtitleData,
|
||||||
SubtitleMiningContext,
|
SubtitleMiningContext,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
|
OverlayNotificationPayload,
|
||||||
|
OverlayNotificationEventPayload,
|
||||||
|
NotificationType,
|
||||||
UpdateChannel,
|
UpdateChannel,
|
||||||
WindowGeometry,
|
WindowGeometry,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -600,6 +603,12 @@ import {
|
|||||||
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 { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||||
|
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
|
||||||
|
import {
|
||||||
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
notifyConfiguredStatus,
|
||||||
|
type ConfiguredStatusNotificationOptions,
|
||||||
|
} from './main/runtime/configured-status-notification';
|
||||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||||
import {
|
import {
|
||||||
runUpdateCliCommand,
|
runUpdateCliCommand,
|
||||||
@@ -1232,7 +1241,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(),
|
||||||
@@ -1467,6 +1476,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 +1726,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 +1748,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 +1782,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 +1807,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({
|
||||||
@@ -2134,7 +2168,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 +2211,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 +2569,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 +2648,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
isMacOSPlatform: () => process.platform === 'darwin',
|
isMacOSPlatform: () => process.platform === 'darwin',
|
||||||
isWindowsPlatform: () => process.platform === 'win32',
|
isWindowsPlatform: () => process.platform === 'win32',
|
||||||
showOverlayLoadingOsd: (message: string) => {
|
showOverlayLoadingOsd: (message: string) => {
|
||||||
showMpvOsd(message);
|
showOverlayLoadingStatusNotification(message);
|
||||||
},
|
},
|
||||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||||
@@ -3293,6 +3330,93 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
|
|||||||
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVisibleOverlayContentReady(): boolean {
|
||||||
|
const overlayWindow = overlayManager.getMainWindow();
|
||||||
|
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredStatusNotificationType(): NotificationType {
|
||||||
|
const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||||
|
if (configuredType === 'none' || isVisibleOverlayContentReady()) {
|
||||||
|
return configuredType;
|
||||||
|
}
|
||||||
|
return 'osd';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||||
|
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||||
|
sendOverlayNotificationEvent(
|
||||||
|
withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissOverlayNotification(id: string): void {
|
||||||
|
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 showOverlayLoadingStatusNotification(message: string): void {
|
||||||
|
showMpvOsd(message);
|
||||||
|
}
|
||||||
|
|
||||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
broadcastRuntimeOptionsChangedRuntime,
|
||||||
@@ -3383,12 +3507,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 +3543,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 +3552,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 +3576,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 +3761,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 +3895,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 +4063,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 +4393,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,
|
||||||
@@ -5014,7 +5144,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,
|
||||||
@@ -5329,13 +5459,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 +5518,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 +5997,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 +6084,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 +6319,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 +6344,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 +6381,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 +6400,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 +6409,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 +6420,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 +6434,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 +6443,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 +6454,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 +6497,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 +6636,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> {
|
||||||
@@ -6555,12 +6693,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 +6720,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 +6740,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(),
|
||||||
@@ -6938,6 +7077,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 +7114,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(),
|
||||||
@@ -7200,6 +7340,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||||
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
|
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
|
||||||
onWindowContentReady: () => {
|
onWindowContentReady: () => {
|
||||||
|
dismissOverlayNotification('overlay-loading-status');
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
if (appState.currentSubText.trim()) {
|
if (appState.currentSubText.trim()) {
|
||||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
@@ -7242,7 +7383,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 +7531,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: () =>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -124,6 +125,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 +223,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'];
|
||||||
@@ -309,6 +312,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 +418,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) =>
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
|
|||||||
|
|
||||||
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
|
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
|
||||||
return (): NotifyAnilistSetupMainDeps => ({
|
return (): NotifyAnilistSetupMainDeps => ({
|
||||||
|
getNotificationType: () => deps.getNotificationType?.(),
|
||||||
hasMpvClient: () => deps.hasMpvClient(),
|
hasMpvClient: () => deps.hasMpvClient(),
|
||||||
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),
|
||||||
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;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('auto sync notifications never send desktop notifications', () => {
|
test('auto sync notifications route both to overlay and system only', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||||
@@ -80,14 +80,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.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 +92,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.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
|
|
||||||
getNotificationType: () => 'both',
|
assert.deepEqual(calls, [
|
||||||
|
'overlay:character-dictionary-auto-sync:Character dictionary:syncing:pin',
|
||||||
|
'desktop:SubMiner:syncing',
|
||||||
|
'overlay:character-dictionary-auto-sync: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 +118,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 +150,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 +159,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}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
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 +16,63 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
|
function shouldShowOsd(type: NotificationType): boolean {
|
||||||
return type !== 'none';
|
return type === 'osd' || type === 'osd-system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallbackToDesktop(
|
function shouldShowOverlay(type: NotificationType): boolean {
|
||||||
type: 'osd' | 'system' | 'both' | 'none' | undefined,
|
return type === 'overlay' || type === 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowDesktop(type: NotificationType): boolean {
|
||||||
|
return type === 'system' || type === 'both' || type === 'osd-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
|
||||||
|
return phase === 'ready' || phase === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayVariantForPhase(
|
||||||
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';
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (shouldShowOsd(type)) {
|
if (type === 'none') return;
|
||||||
if (deps.startupOsdSequencer) {
|
|
||||||
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
if (shouldShowOverlay(type)) {
|
||||||
phase: event.phase,
|
if (deps.showOverlayNotification) {
|
||||||
message: event.message,
|
deps.showOverlayNotification({
|
||||||
|
id: 'character-dictionary-auto-sync',
|
||||||
|
title: 'Character dictionary',
|
||||||
|
body: event.message,
|
||||||
|
variant: overlayVariantForPhase(event.phase),
|
||||||
|
persistent: !isTerminalPhase(event.phase),
|
||||||
});
|
});
|
||||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
} else if (!shouldShowDesktop(type)) {
|
||||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const shown = deps.showOsd(event.message) !== false;
|
|
||||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
|
||||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowOsd(type)) {
|
||||||
|
if (deps.startupOsdSequencer) {
|
||||||
|
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||||
|
phase: event.phase,
|
||||||
|
message: event.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowDesktop(type)) {
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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 => {
|
||||||
deps.showMpvOsd(message);
|
const type = deps.getNotificationType?.() ?? 'osd-system';
|
||||||
deps.showDesktopNotification('SubMiner', { body: message });
|
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);
|
||||||
|
}
|
||||||
|
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||||
|
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,174 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
notifyConfiguredStatus,
|
||||||
|
} from './configured-status-notification';
|
||||||
|
|
||||||
|
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 routes pre-overlay status to osd only', () => {
|
||||||
|
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, ['osd: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.']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowOverlay(type: NotificationType): boolean {
|
||||||
|
return type === 'overlay' || type === 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowOsd(type: NotificationType): boolean {
|
||||||
|
return type === 'osd' || type === 'osd-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowDesktop(type: NotificationType): boolean {
|
||||||
|
return type === 'system' || type === 'both' || type === 'osd-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (deps.isOverlayReady?.() === false) {
|
||||||
|
deps.showOsd(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOverlay) {
|
||||||
|
if (deps.showOverlayNotification) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
},
|
},
|
||||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
|
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
|
||||||
replayCurrentSubtitle: () => calls.push('replay'),
|
replayCurrentSubtitle: () => calls.push('replay'),
|
||||||
playNextSubtitle: () => calls.push('next'),
|
playNextSubtitle: () => calls.push('next'),
|
||||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||||
@@ -34,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
void deps.openPlaylistBrowser();
|
void deps.openPlaylistBrowser();
|
||||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||||
deps.showMpvOsd('hello');
|
deps.showMpvOsd('hello');
|
||||||
|
deps.showPlaybackFeedback?.('primary');
|
||||||
deps.replayCurrentSubtitle();
|
deps.replayCurrentSubtitle();
|
||||||
deps.playNextSubtitle();
|
deps.playNextSubtitle();
|
||||||
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||||
@@ -48,6 +50,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
'youtube-picker',
|
'youtube-picker',
|
||||||
'playlist-browser',
|
'playlist-browser',
|
||||||
'osd:hello',
|
'osd:hello',
|
||||||
|
'feedback:primary',
|
||||||
'replay',
|
'replay',
|
||||||
'next',
|
'next',
|
||||||
'shift:next',
|
'shift:next',
|
||||||
|
|||||||
@@ -3,20 +3,27 @@ import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
|
|||||||
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||||
deps: MpvCommandFromIpcRuntimeDeps,
|
deps: MpvCommandFromIpcRuntimeDeps,
|
||||||
) {
|
) {
|
||||||
return (): MpvCommandFromIpcRuntimeDeps => ({
|
return (): MpvCommandFromIpcRuntimeDeps => {
|
||||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
const showPlaybackFeedback = deps.showPlaybackFeedback;
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
return {
|
||||||
openJimaku: () => deps.openJimaku(),
|
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
openJimaku: () => deps.openJimaku(),
|
||||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
|
...(showPlaybackFeedback
|
||||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
|
||||||
getMpvClient: () => deps.getMpvClient(),
|
: {}),
|
||||||
isMpvConnected: () => deps.isMpvConnected(),
|
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||||
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||||
});
|
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||||
|
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||||
|
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||||
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
|
isMpvConnected: () => deps.isMpvConnected(),
|
||||||
|
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
|||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
|
osdMessages: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
aniskipEnabled: true,
|
||||||
aniskipButtonKey: 'F8',
|
aniskipButtonKey: 'F8',
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
|
||||||
|
|
||||||
|
test('overlay notification payloads inherit configured overlay position', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
withConfiguredOverlayNotificationPosition(
|
||||||
|
{ title: 'SubMiner', body: 'Ready' },
|
||||||
|
{ notifications: { overlayPosition: 'top' } },
|
||||||
|
),
|
||||||
|
{ title: 'SubMiner', body: 'Ready', position: 'top' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification payload position can override configured position', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
withConfiguredOverlayNotificationPosition(
|
||||||
|
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
|
||||||
|
{ notifications: { overlayPosition: 'top-right' } },
|
||||||
|
),
|
||||||
|
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
|
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
|
export function withConfiguredOverlayNotificationPosition(
|
||||||
|
payload: OverlayNotificationPayload,
|
||||||
|
config: Pick<ResolvedConfig, 'notifications'>,
|
||||||
|
): OverlayNotificationPayload {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
position: payload.position ?? config.notifications.overlayPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnkiConnectConfig } from '../../types';
|
import type { AnkiConnectConfig } from '../../types';
|
||||||
|
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||||
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
|
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
|
||||||
|
|
||||||
type OverlayRuntimeOptionsMainDeps = Parameters<
|
type OverlayRuntimeOptionsMainDeps = Parameters<
|
||||||
@@ -37,6 +38,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
shouldStartAnkiIntegration: () => boolean;
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
@@ -72,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
deps.appState.ankiIntegration = integration;
|
deps.appState.ankiIntegration = integration;
|
||||||
},
|
},
|
||||||
showDesktopNotification: deps.showDesktopNotification,
|
showDesktopNotification: deps.showDesktopNotification,
|
||||||
|
showOverlayNotification: deps.showOverlayNotification,
|
||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '../../types/anki';
|
} from '../../types/anki';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { WindowGeometry } from '../../types/runtime';
|
import type { WindowGeometry } from '../../types/runtime';
|
||||||
|
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||||
import type { BaseWindowTracker } from '../../window-trackers';
|
import type { BaseWindowTracker } from '../../window-trackers';
|
||||||
|
|
||||||
type OverlayRuntimeOptions = {
|
type OverlayRuntimeOptions = {
|
||||||
@@ -31,6 +32,7 @@ type OverlayRuntimeOptions = {
|
|||||||
} | null;
|
} | 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>;
|
||||||
@@ -64,6 +66,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
} | null;
|
} | 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>;
|
||||||
@@ -91,6 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
|
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
|
||||||
setAnkiIntegration: deps.setAnkiIntegration,
|
setAnkiIntegration: deps.setAnkiIntegration,
|
||||||
showDesktopNotification: deps.showDesktopNotification,
|
showDesktopNotification: deps.showDesktopNotification,
|
||||||
|
showOverlayNotification: deps.showOverlayNotification,
|
||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
|
|||||||
@@ -222,3 +222,35 @@ test('startup OSD keeps dictionary progress pending when mpv osd is unavailable'
|
|||||||
'Character dictionary ready for Frieren',
|
'Character dictionary ready for Frieren',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startup notifications route tokenization and annotation status to overlay and system without osd for both', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
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 ?? ''}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.showTokenizationLoading('Loading subtitle tokenization...');
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'overlay:startup-tokenization:Subtitle tokenization:Loading subtitle tokenization...:progress:pin',
|
||||||
|
'overlay:startup-tokenization:Subtitle tokenization:Subtitle tokenization ready:success:auto',
|
||||||
|
'desktop:SubMiner:Subtitle tokenization ready',
|
||||||
|
'overlay:startup-subtitle-annotations:Subtitle annotations:Loading subtitle annotations |:progress:pin',
|
||||||
|
'overlay:startup-subtitle-annotations:Subtitle annotations:Subtitle annotations loaded:success:auto',
|
||||||
|
'desktop:SubMiner:Subtitle annotations loaded',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
|
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||||
|
|
||||||
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
||||||
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => boolean | void }): {
|
export interface StartupOsdSequencerDeps {
|
||||||
|
getNotificationType?: () => NotificationType | undefined;
|
||||||
|
showOsd: (message: string) => boolean | void;
|
||||||
|
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||||
|
showDesktopNotification?: (title: string, options: { body?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartupStatusNotificationOptions {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
variant: OverlayNotificationPayload['variant'];
|
||||||
|
persistent: boolean;
|
||||||
|
desktop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowOsd(type: NotificationType): boolean {
|
||||||
|
return type === 'osd' || type === 'osd-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowOverlay(type: NotificationType): boolean {
|
||||||
|
return type === 'overlay' || type === 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowDesktop(type: NotificationType): boolean {
|
||||||
|
return type === 'system' || type === 'both' || type === 'osd-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
showTokenizationLoading: (message: string) => void;
|
||||||
markTokenizationReady: () => void;
|
markTokenizationReady: () => void;
|
||||||
showAnnotationLoading: (message: string) => void;
|
showAnnotationLoading: (message: string) => void;
|
||||||
markAnnotationLoadingComplete: (message: string) => void;
|
markAnnotationLoadingComplete: (message: string) => void;
|
||||||
@@ -12,6 +43,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
} {
|
} {
|
||||||
let tokenizationReady = false;
|
let tokenizationReady = false;
|
||||||
let tokenizationWarmupCompleted = false;
|
let tokenizationWarmupCompleted = false;
|
||||||
|
let tokenizationLoadingShown = false;
|
||||||
let annotationLoadingMessage: string | null = null;
|
let annotationLoadingMessage: string | null = null;
|
||||||
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||||
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||||
@@ -20,7 +52,66 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
|
|
||||||
const canShowDictionaryStatus = (): boolean =>
|
const canShowDictionaryStatus = (): boolean =>
|
||||||
tokenizationReady && annotationLoadingMessage === null;
|
tokenizationReady && annotationLoadingMessage === null;
|
||||||
const showOsd = (message: string): boolean => deps.showOsd(message) !== false;
|
const getNotificationType = (): NotificationType => deps.getNotificationType?.() ?? 'osd';
|
||||||
|
const notifyStartupStatus = (options: StartupStatusNotificationOptions): boolean => {
|
||||||
|
const type = getNotificationType();
|
||||||
|
if (type === 'none') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let shown = false;
|
||||||
|
if (shouldShowOverlay(type)) {
|
||||||
|
deps.showOverlayNotification?.({
|
||||||
|
id: options.id,
|
||||||
|
title: options.title,
|
||||||
|
body: options.message,
|
||||||
|
variant: options.variant,
|
||||||
|
persistent: options.persistent,
|
||||||
|
});
|
||||||
|
shown = true;
|
||||||
|
}
|
||||||
|
if (shouldShowOsd(type)) {
|
||||||
|
shown = deps.showOsd(options.message) !== false || shown;
|
||||||
|
}
|
||||||
|
if (options.desktop !== false && shouldShowDesktop(type)) {
|
||||||
|
deps.showDesktopNotification?.('SubMiner', { body: options.message });
|
||||||
|
shown = true;
|
||||||
|
}
|
||||||
|
return shown;
|
||||||
|
};
|
||||||
|
const showOsd = (message: string): boolean =>
|
||||||
|
notifyStartupStatus({
|
||||||
|
id: 'startup-status',
|
||||||
|
title: 'SubMiner',
|
||||||
|
message,
|
||||||
|
variant: 'info',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
const notifyTokenization = (
|
||||||
|
message: string,
|
||||||
|
variant: OverlayNotificationPayload['variant'],
|
||||||
|
persistent: boolean,
|
||||||
|
): boolean =>
|
||||||
|
notifyStartupStatus({
|
||||||
|
id: 'startup-tokenization',
|
||||||
|
title: 'Subtitle tokenization',
|
||||||
|
message,
|
||||||
|
variant,
|
||||||
|
persistent,
|
||||||
|
desktop: !persistent,
|
||||||
|
});
|
||||||
|
const notifyAnnotation = (
|
||||||
|
message: string,
|
||||||
|
variant: OverlayNotificationPayload['variant'],
|
||||||
|
persistent: boolean,
|
||||||
|
): boolean =>
|
||||||
|
notifyStartupStatus({
|
||||||
|
id: 'startup-subtitle-annotations',
|
||||||
|
title: 'Subtitle annotations',
|
||||||
|
message,
|
||||||
|
variant,
|
||||||
|
persistent,
|
||||||
|
desktop: !persistent,
|
||||||
|
});
|
||||||
|
|
||||||
const flushBufferedDictionaryStatus = (): boolean => {
|
const flushBufferedDictionaryStatus = (): boolean => {
|
||||||
if (!canShowDictionaryStatus()) {
|
if (!canShowDictionaryStatus()) {
|
||||||
@@ -55,17 +146,29 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
return {
|
return {
|
||||||
reset: () => {
|
reset: () => {
|
||||||
tokenizationReady = tokenizationWarmupCompleted;
|
tokenizationReady = tokenizationWarmupCompleted;
|
||||||
|
tokenizationLoadingShown = false;
|
||||||
annotationLoadingMessage = null;
|
annotationLoadingMessage = null;
|
||||||
pendingDictionaryProgress = null;
|
pendingDictionaryProgress = null;
|
||||||
pendingDictionaryFailure = null;
|
pendingDictionaryFailure = null;
|
||||||
pendingDictionaryReady = null;
|
pendingDictionaryReady = null;
|
||||||
dictionaryProgressShown = false;
|
dictionaryProgressShown = false;
|
||||||
},
|
},
|
||||||
|
showTokenizationLoading: (message) => {
|
||||||
|
if (tokenizationReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokenizationLoadingShown = true;
|
||||||
|
notifyTokenization(message, 'progress', true);
|
||||||
|
},
|
||||||
markTokenizationReady: () => {
|
markTokenizationReady: () => {
|
||||||
tokenizationWarmupCompleted = true;
|
tokenizationWarmupCompleted = true;
|
||||||
tokenizationReady = true;
|
tokenizationReady = true;
|
||||||
|
if (tokenizationLoadingShown) {
|
||||||
|
notifyTokenization('Subtitle tokenization ready', 'success', false);
|
||||||
|
tokenizationLoadingShown = false;
|
||||||
|
}
|
||||||
if (annotationLoadingMessage !== null) {
|
if (annotationLoadingMessage !== null) {
|
||||||
showOsd(annotationLoadingMessage);
|
notifyAnnotation(annotationLoadingMessage, 'progress', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flushBufferedDictionaryStatus();
|
flushBufferedDictionaryStatus();
|
||||||
@@ -73,7 +176,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
showAnnotationLoading: (message) => {
|
showAnnotationLoading: (message) => {
|
||||||
annotationLoadingMessage = message;
|
annotationLoadingMessage = message;
|
||||||
if (tokenizationReady) {
|
if (tokenizationReady) {
|
||||||
showOsd(message);
|
notifyAnnotation(message, 'progress', true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markAnnotationLoadingComplete: (message) => {
|
markAnnotationLoadingComplete: (message) => {
|
||||||
@@ -84,7 +187,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
|
|||||||
if (flushBufferedDictionaryStatus()) {
|
if (flushBufferedDictionaryStatus()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showOsd(message);
|
notifyAnnotation(message, 'success', false);
|
||||||
},
|
},
|
||||||
notifyCharacterDictionaryStatus: (event) => {
|
notifyCharacterDictionaryStatus: (event) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { notifyUpdateAvailable } from './update-notifications';
|
import { notifyUpdateAvailable } from './update-notifications';
|
||||||
|
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||||
|
|
||||||
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
|
test('notifyUpdateAvailable routes notification surfaces from config', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps = {
|
const deps = {
|
||||||
showSystemNotification: (title: string, body: string) => {
|
showSystemNotification: (title: string, body: string) => {
|
||||||
@@ -11,19 +12,27 @@ test('notifyUpdateAvailable routes system and osd notifications from config', as
|
|||||||
showOsdNotification: async (message: string) => {
|
showOsdNotification: async (message: string) => {
|
||||||
calls.push(`osd:${message}`);
|
calls.push(`osd:${message}`);
|
||||||
},
|
},
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => {
|
||||||
|
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`);
|
||||||
|
},
|
||||||
log: (message: string) => {
|
log: (message: string) => {
|
||||||
calls.push(`log:${message}`);
|
calls.push(`log:${message}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await notifyUpdateAvailable({ notificationType: 'overlay', version: '0.15.0' }, deps);
|
||||||
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
|
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
|
||||||
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
|
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
|
||||||
|
await notifyUpdateAvailable({ notificationType: 'osd-system', version: '0.15.0' }, deps);
|
||||||
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
|
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
|
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
|
||||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||||
|
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
|
||||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||||
'osd:SubMiner v0.15.0 is available',
|
'osd:SubMiner v0.15.0 is available',
|
||||||
|
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +48,9 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
|
|||||||
showOsdNotification: async () => {
|
showOsdNotification: async () => {
|
||||||
throw new Error('mpv disconnected');
|
throw new Error('mpv disconnected');
|
||||||
},
|
},
|
||||||
|
showOverlayNotification: () => {
|
||||||
|
calls.push('overlay');
|
||||||
|
},
|
||||||
log: (message) => {
|
log: (message) => {
|
||||||
calls.push(message);
|
calls.push(message);
|
||||||
},
|
},
|
||||||
@@ -60,6 +72,9 @@ test('notifyUpdateAvailable logs non-error osd failures with thrown value', asyn
|
|||||||
showOsdNotification: async () => {
|
showOsdNotification: async () => {
|
||||||
throw 'mpv disconnected';
|
throw 'mpv disconnected';
|
||||||
},
|
},
|
||||||
|
showOverlayNotification: () => {
|
||||||
|
calls.push('overlay');
|
||||||
|
},
|
||||||
log: (message) => {
|
log: (message) => {
|
||||||
calls.push(message);
|
calls.push(message);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { UpdateNotificationType } from '../../../types/config';
|
import type { UpdateNotificationType } from '../../../types/config';
|
||||||
|
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||||
|
|
||||||
export interface UpdateNotificationDeps {
|
export interface UpdateNotificationDeps {
|
||||||
showSystemNotification: (title: string, body: string) => void;
|
showSystemNotification: (title: string, body: string) => void;
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
showOsdNotification: (message: string) => void | Promise<void>;
|
showOsdNotification: (message: string) => void | Promise<void>;
|
||||||
log: (message: string) => void;
|
log: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -13,10 +15,14 @@ export async function notifyUpdateAvailable(
|
|||||||
if (options.notificationType === 'none') return;
|
if (options.notificationType === 'none') return;
|
||||||
|
|
||||||
const message = `SubMiner v${options.version} is available`;
|
const message = `SubMiner v${options.version} is available`;
|
||||||
if (options.notificationType === 'system' || options.notificationType === 'both') {
|
if (options.notificationType === 'overlay' || options.notificationType === 'both') {
|
||||||
deps.showSystemNotification('SubMiner update available', message);
|
deps.showOverlayNotification({
|
||||||
|
title: 'SubMiner update available',
|
||||||
|
body: message,
|
||||||
|
variant: 'info',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (options.notificationType === 'osd' || options.notificationType === 'both') {
|
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||||
try {
|
try {
|
||||||
await deps.showOsdNotification(message);
|
await deps.showOsdNotification(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -24,4 +30,11 @@ export async function notifyUpdateAvailable(
|
|||||||
deps.log(`Update OSD notification failed: ${reason}`);
|
deps.log(`Update OSD notification failed: ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
options.notificationType === 'system' ||
|
||||||
|
options.notificationType === 'both' ||
|
||||||
|
options.notificationType === 'osd-system'
|
||||||
|
) {
|
||||||
|
deps.showSystemNotification('SubMiner update available', message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
|||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
|
osdMessages: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
aniskipEnabled: true,
|
||||||
aniskipButtonKey: 'F8',
|
aniskipButtonKey: 'F8',
|
||||||
@@ -242,6 +243,7 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over
|
|||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
osdMessages: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
aniskipEnabled: true,
|
||||||
aniskipButtonKey: 'F7',
|
aniskipButtonKey: 'F7',
|
||||||
@@ -292,6 +294,7 @@ test('launchWindowsMpv attaches a launched video to a running app and disables p
|
|||||||
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',
|
||||||
@@ -356,6 +359,7 @@ test('launchWindowsMpv leaves plugin auto-start enabled when no running app cont
|
|||||||
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',
|
||||||
@@ -446,6 +450,7 @@ test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async
|
|||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
osdMessages: false,
|
||||||
texthookerEnabled: false,
|
texthookerEnabled: false,
|
||||||
aniskipEnabled: true,
|
aniskipEnabled: true,
|
||||||
aniskipButtonKey: 'TAB',
|
aniskipButtonKey: 'TAB',
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
YoutubePickerOpenPayload,
|
YoutubePickerOpenPayload,
|
||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
|
OverlayNotificationEventPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -206,6 +207,11 @@ const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>
|
|||||||
const onOverlayPointerRecoveryRequestEvent = createQueuedIpcListener(
|
const onOverlayPointerRecoveryRequestEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.overlayPointerRecoveryRequest,
|
IPC_CHANNELS.event.overlayPointerRecoveryRequest,
|
||||||
);
|
);
|
||||||
|
const onOverlayNotificationEvent =
|
||||||
|
createQueuedIpcListenerWithPayload<OverlayNotificationEventPayload>(
|
||||||
|
IPC_CHANNELS.event.overlayNotification,
|
||||||
|
(payload) => payload as OverlayNotificationEventPayload,
|
||||||
|
);
|
||||||
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
||||||
IPC_CHANNELS.event.subtitleVisibility,
|
IPC_CHANNELS.event.subtitleVisibility,
|
||||||
(payload) => payload === true,
|
(payload) => payload === true,
|
||||||
@@ -229,6 +235,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
onSubtitleSetEvent(callback);
|
onSubtitleSetEvent(callback);
|
||||||
},
|
},
|
||||||
onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent,
|
onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent,
|
||||||
|
onOverlayNotification: onOverlayNotificationEvent,
|
||||||
|
sendOverlayNotificationAction: (notificationId: string, actionId: string) => {
|
||||||
|
ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId });
|
||||||
|
},
|
||||||
|
|
||||||
onVisibility: (callback: (visible: boolean) => void) => {
|
onVisibility: (callback: (visible: boolean) => void) => {
|
||||||
onSubtitleVisibilityEvent(callback);
|
onSubtitleVisibilityEvent(callback);
|
||||||
|
|||||||
@@ -42,6 +42,12 @@
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
></div>
|
></div>
|
||||||
|
<div
|
||||||
|
id="overlayNotificationStack"
|
||||||
|
class="overlay-notification-stack position-top-right hidden"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
></div>
|
||||||
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
||||||
<div id="secondarySubRoot"></div>
|
<div id="secondarySubRoot"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,3 +166,88 @@ test('overlay measurement includes open subtitle sidebar bounds as an interactiv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('overlay measurement includes overlay notification stack bounds as an interactive rect', () => {
|
||||||
|
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
const reports: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
innerWidth: 1920,
|
||||||
|
innerHeight: 1080,
|
||||||
|
electronAPI: {
|
||||||
|
reportOverlayContentBounds: (payload: unknown) => {
|
||||||
|
reports.push(payload);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reporter = createOverlayContentMeasurementReporter({
|
||||||
|
platform: { overlayLayer: 'visible' },
|
||||||
|
state: { subtitleSidebarModalOpen: false },
|
||||||
|
dom: {
|
||||||
|
subtitleRoot: makeElement('', {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect),
|
||||||
|
subtitleContainer: makeElement('', {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect),
|
||||||
|
secondarySubRoot: makeElement('', {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect),
|
||||||
|
secondarySubContainer: makeElement('', {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect),
|
||||||
|
overlayNotificationStack: {
|
||||||
|
children: [{}, {}],
|
||||||
|
getBoundingClientRect: () =>
|
||||||
|
({
|
||||||
|
left: 1540,
|
||||||
|
top: 16,
|
||||||
|
width: 360,
|
||||||
|
height: 220,
|
||||||
|
}) as DOMRect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
reporter.emitNow();
|
||||||
|
|
||||||
|
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
|
||||||
|
if (typeof measuredAtMs !== 'number') {
|
||||||
|
assert.fail('Expected report timestamp.');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(reports, [
|
||||||
|
{
|
||||||
|
layer: 'visible',
|
||||||
|
measuredAtMs,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
contentRect: { x: 1540, y: 16, width: 360, height: 220 },
|
||||||
|
interactiveRects: [{ x: 1540, y: 16, width: 360, height: 220 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (originalWindow) {
|
||||||
|
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { window?: unknown }).window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.dom.overlayNotificationStack?.children.length > 0) {
|
||||||
|
const notificationRect = toMeasuredRect(
|
||||||
|
ctx.dom.overlayNotificationStack.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
if (notificationRect && hasArea(notificationRect)) {
|
||||||
|
rects.push(notificationRect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rects;
|
return rects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
|||||||
const shouldKeepWindowInteractive =
|
const shouldKeepWindowInteractive =
|
||||||
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
|
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
|
||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
|
ctx.state.isOverSubtitle ||
|
||||||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
|
ctx.state.isOverOverlayNotification ||
|
||||||
|
shouldKeepWindowInteractive;
|
||||||
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
|
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
|
||||||
? shouldKeepWindowInteractive
|
? shouldKeepWindowInteractive
|
||||||
: shouldStayInteractive;
|
: shouldStayInteractive;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOverlayNotificationStore,
|
||||||
|
handleOverlayNotificationEvent,
|
||||||
|
overlayNotificationPositionClass,
|
||||||
|
} from './overlay-notifications';
|
||||||
|
|
||||||
|
test('overlay notification store caps transient notifications and keeps pinned jobs visible', () => {
|
||||||
|
const store = createOverlayNotificationStore({ maxVisible: 3 });
|
||||||
|
|
||||||
|
store.upsert({
|
||||||
|
id: 'character-dictionary-auto-sync',
|
||||||
|
title: 'Character dictionary',
|
||||||
|
body: 'Generating character dictionary',
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
store.upsert({ id: 'one', title: 'One', body: 'First' });
|
||||||
|
store.upsert({ id: 'two', title: 'Two', body: 'Second' });
|
||||||
|
store.upsert({ id: 'three', title: 'Three', body: 'Third' });
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
store.visible().map((entry) => entry.id),
|
||||||
|
['character-dictionary-auto-sync', 'two', 'three'],
|
||||||
|
);
|
||||||
|
|
||||||
|
store.upsert({
|
||||||
|
id: 'character-dictionary-auto-sync',
|
||||||
|
title: 'Character dictionary',
|
||||||
|
body: 'Ready',
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
store.visible().map((entry) => `${entry.id}:${entry.body}`),
|
||||||
|
['two:Second', 'three:Third', 'character-dictionary-auto-sync:Ready'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification positions map to stack alignment classes', () => {
|
||||||
|
assert.equal(overlayNotificationPositionClass(undefined), 'position-top-right');
|
||||||
|
assert.equal(overlayNotificationPositionClass('top-left'), 'position-top-left');
|
||||||
|
assert.equal(overlayNotificationPositionClass('top'), 'position-top');
|
||||||
|
assert.equal(overlayNotificationPositionClass('top-right'), 'position-top-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay notification event handler dismisses notifications by id', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
handleOverlayNotificationEvent(
|
||||||
|
{
|
||||||
|
show: (payload) => {
|
||||||
|
calls.push(`show:${payload.id ?? ''}:${payload.title}`);
|
||||||
|
return payload.id ?? '';
|
||||||
|
},
|
||||||
|
remove: (id) => {
|
||||||
|
calls.push(`remove:${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'overlay-loading-status', dismiss: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['remove:overlay-loading-status']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type {
|
||||||
|
OverlayNotificationDismissPayload,
|
||||||
|
OverlayNotificationEventPayload,
|
||||||
|
OverlayNotificationPayload,
|
||||||
|
OverlayNotificationPosition,
|
||||||
|
OverlayNotificationVariant,
|
||||||
|
} from '../types';
|
||||||
|
import type { RendererContext } from './context';
|
||||||
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
|
|
||||||
|
export const DEFAULT_OVERLAY_NOTIFICATION_TIMEOUT_MS = 3000;
|
||||||
|
export const DEFAULT_OVERLAY_NOTIFICATION_MAX_VISIBLE = 3;
|
||||||
|
export const DEFAULT_OVERLAY_NOTIFICATION_POSITION: OverlayNotificationPosition = 'top-right';
|
||||||
|
const OVERLAY_NOTIFICATION_POSITION_CLASSES = [
|
||||||
|
'position-top-left',
|
||||||
|
'position-top',
|
||||||
|
'position-top-right',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OverlayNotificationEntry = Required<
|
||||||
|
Pick<OverlayNotificationPayload, 'id' | 'title' | 'persistent'>
|
||||||
|
> &
|
||||||
|
Omit<OverlayNotificationPayload, 'id' | 'title' | 'persistent'> & {
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverlayNotificationStoreOptions = {
|
||||||
|
maxVisible?: number;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverlayNotificationController = {
|
||||||
|
show: (payload: OverlayNotificationPayload) => string;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createOverlayNotificationStore(options: OverlayNotificationStoreOptions = {}) {
|
||||||
|
const maxVisible = Math.max(1, options.maxVisible ?? DEFAULT_OVERLAY_NOTIFICATION_MAX_VISIBLE);
|
||||||
|
const now = options.now ?? (() => Date.now());
|
||||||
|
const entries: OverlayNotificationEntry[] = [];
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
function visible(): OverlayNotificationEntry[] {
|
||||||
|
const pinned = entries.filter((entry) => entry.persistent);
|
||||||
|
const transientSlots = Math.max(0, maxVisible - pinned.length);
|
||||||
|
const transient =
|
||||||
|
transientSlots === 0
|
||||||
|
? []
|
||||||
|
: entries.filter((entry) => !entry.persistent).slice(-transientSlots);
|
||||||
|
return [...pinned, ...transient];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHiddenTransient(): void {
|
||||||
|
const visibleIds = new Set(visible().map((entry) => entry.id));
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (!entry) continue;
|
||||||
|
if (!entry.persistent && !visibleIds.has(entry.id)) {
|
||||||
|
entries.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsert(payload: OverlayNotificationPayload): OverlayNotificationEntry {
|
||||||
|
const id = payload.id ?? `overlay-notification-${nextId++}`;
|
||||||
|
const existingIndex = entries.findIndex((entry) => entry.id === id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
entries.splice(existingIndex, 1);
|
||||||
|
}
|
||||||
|
const entry: OverlayNotificationEntry = {
|
||||||
|
...payload,
|
||||||
|
id,
|
||||||
|
title: payload.title,
|
||||||
|
persistent: Boolean(payload.persistent),
|
||||||
|
createdAt: now(),
|
||||||
|
};
|
||||||
|
entries.push(entry);
|
||||||
|
pruneHiddenTransient();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string): void {
|
||||||
|
const index = entries.findIndex((entry) => entry.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
entries.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upsert,
|
||||||
|
remove,
|
||||||
|
visible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayNotificationPositionClass(
|
||||||
|
position: OverlayNotificationPosition | undefined,
|
||||||
|
): string {
|
||||||
|
return `position-${position ?? DEFAULT_OVERLAY_NOTIFICATION_POSITION}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlayNotificationDismissPayload(
|
||||||
|
payload: OverlayNotificationEventPayload,
|
||||||
|
): payload is OverlayNotificationDismissPayload {
|
||||||
|
return 'dismiss' in payload && payload.dismiss === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleOverlayNotificationEvent(
|
||||||
|
controller: OverlayNotificationController,
|
||||||
|
payload: OverlayNotificationEventPayload,
|
||||||
|
): string | null {
|
||||||
|
if (isOverlayNotificationDismissPayload(payload)) {
|
||||||
|
controller.remove(payload.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return controller.show(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariant(
|
||||||
|
variant: OverlayNotificationVariant | undefined,
|
||||||
|
): OverlayNotificationVariant {
|
||||||
|
return variant ?? 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInteractiveState(ctx: RendererContext, value: boolean): void {
|
||||||
|
ctx.state.isOverOverlayNotification = value;
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayNotificationRenderer(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: { onChanged?: () => void } = {},
|
||||||
|
) {
|
||||||
|
const store = createOverlayNotificationStore();
|
||||||
|
const timers = new Map<string, number>();
|
||||||
|
let position: OverlayNotificationPosition = DEFAULT_OVERLAY_NOTIFICATION_POSITION;
|
||||||
|
|
||||||
|
function clearTimer(id: string): void {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer !== undefined) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: string): void {
|
||||||
|
clearTimer(id);
|
||||||
|
store.remove(id);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
const visible = store.visible();
|
||||||
|
ctx.dom.overlayNotificationStack.replaceChildren();
|
||||||
|
ctx.dom.overlayNotificationStack.classList.toggle('hidden', visible.length === 0);
|
||||||
|
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES);
|
||||||
|
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
|
||||||
|
|
||||||
|
for (const entry of visible) {
|
||||||
|
const card = document.createElement('section');
|
||||||
|
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}`;
|
||||||
|
card.dataset.notificationId = entry.id;
|
||||||
|
card.setAttribute('role', 'status');
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'overlay-notification-icon';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'overlay-notification-content';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'overlay-notification-title';
|
||||||
|
title.textContent = entry.title;
|
||||||
|
content.append(title);
|
||||||
|
|
||||||
|
if (entry.body && entry.body.trim().length > 0) {
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'overlay-notification-body';
|
||||||
|
body.textContent = entry.body;
|
||||||
|
content.append(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.actions && entry.actions.length > 0) {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'overlay-notification-actions';
|
||||||
|
for (const action of entry.actions) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'overlay-notification-action';
|
||||||
|
button.textContent = action.label;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id);
|
||||||
|
remove(entry.id);
|
||||||
|
});
|
||||||
|
actions.append(button);
|
||||||
|
}
|
||||||
|
content.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = document.createElement('button');
|
||||||
|
closeButton.type = 'button';
|
||||||
|
closeButton.className = 'overlay-notification-close';
|
||||||
|
closeButton.setAttribute('aria-label', 'Dismiss notification');
|
||||||
|
closeButton.textContent = '×';
|
||||||
|
closeButton.addEventListener('click', () => remove(entry.id));
|
||||||
|
|
||||||
|
card.append(icon, content, closeButton);
|
||||||
|
ctx.dom.overlayNotificationStack.append(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
setInteractiveState(ctx, false);
|
||||||
|
}
|
||||||
|
options.onChanged?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.dom.overlayNotificationStack.addEventListener('mouseenter', () => {
|
||||||
|
setInteractiveState(ctx, true);
|
||||||
|
});
|
||||||
|
ctx.dom.overlayNotificationStack.addEventListener('mouseleave', () => {
|
||||||
|
setInteractiveState(ctx, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function show(payload: OverlayNotificationPayload): string {
|
||||||
|
const entry = store.upsert(payload);
|
||||||
|
position = entry.position ?? DEFAULT_OVERLAY_NOTIFICATION_POSITION;
|
||||||
|
clearTimer(entry.id);
|
||||||
|
if (!entry.persistent) {
|
||||||
|
const timeoutMs = Math.max(0, entry.timeoutMs ?? DEFAULT_OVERLAY_NOTIFICATION_TIMEOUT_MS);
|
||||||
|
timers.set(
|
||||||
|
entry.id,
|
||||||
|
window.setTimeout(() => remove(entry.id), timeoutMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
remove,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js'
|
|||||||
import { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
|
import {
|
||||||
|
createOverlayNotificationRenderer,
|
||||||
|
handleOverlayNotificationEvent,
|
||||||
|
} from './overlay-notifications.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
|
||||||
@@ -112,6 +116,9 @@ function syncSettingsModalSubtitleSuppression(): void {
|
|||||||
|
|
||||||
const subtitleRenderer = createSubtitleRenderer(ctx);
|
const subtitleRenderer = createSubtitleRenderer(ctx);
|
||||||
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
|
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
|
||||||
|
const overlayNotifications = createOverlayNotificationRenderer(ctx, {
|
||||||
|
onChanged: () => measurementReporter.schedule(),
|
||||||
|
});
|
||||||
const positioning = createPositioningController(ctx);
|
const positioning = createPositioningController(ctx);
|
||||||
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
@@ -612,6 +619,11 @@ async function init(): Promise<void> {
|
|||||||
mouseHandlers.restorePointerInteractionState();
|
mouseHandlers.restorePointerInteractionState();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOverlayNotification((payload) => {
|
||||||
|
runGuarded('overlay:notification', () => {
|
||||||
|
handleOverlayNotificationEvent(overlayNotifications, payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
await keyboardHandlers.setupMpvInputForwarding();
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type ChordAction =
|
|||||||
export type RendererState = {
|
export type RendererState = {
|
||||||
isOverSubtitle: boolean;
|
isOverSubtitle: boolean;
|
||||||
isOverSubtitleSidebar: boolean;
|
isOverSubtitleSidebar: boolean;
|
||||||
|
isOverOverlayNotification: boolean;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragStartY: number;
|
dragStartY: number;
|
||||||
startYPercent: number;
|
startYPercent: number;
|
||||||
@@ -143,6 +144,7 @@ export function createRendererState(): RendererState {
|
|||||||
return {
|
return {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
|
isOverOverlayNotification: false,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragStartY: 0,
|
dragStartY: 0,
|
||||||
startYPercent: 0,
|
startYPercent: 0,
|
||||||
|
|||||||
@@ -146,6 +146,226 @@ body:focus-visible,
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-notification-stack {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 1350;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-stack.position-top-left {
|
||||||
|
left: 16px;
|
||||||
|
right: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-stack.position-top {
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-stack.position-top-right {
|
||||||
|
left: auto;
|
||||||
|
right: 16px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card {
|
||||||
|
/* Accent color is overridden per variant and drives the bar, icon, and borders. */
|
||||||
|
--overlay-notification-accent: var(--ctp-blue);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) 22px;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: start;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 13px 14px 13px 17px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 24%, var(--ctp-surface1));
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--overlay-notification-accent) 9%, var(--ctp-surface0)),
|
||||||
|
var(--ctp-mantle)
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 14px 32px -10px rgba(24, 25, 38, 0.75),
|
||||||
|
0 1px 0 rgba(202, 211, 245, 0.05) inset;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: overlay-notification-enter 140ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 11px;
|
||||||
|
bottom: 11px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--overlay-notification-accent);
|
||||||
|
box-shadow: 0 0 11px -1px var(--overlay-notification-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.info {
|
||||||
|
--overlay-notification-accent: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.progress {
|
||||||
|
--overlay-notification-accent: var(--ctp-sky);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.success {
|
||||||
|
--overlay-notification-accent: var(--ctp-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.warning {
|
||||||
|
--overlay-notification-accent: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.error {
|
||||||
|
--overlay-notification-accent: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
align-self: center;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: color-mix(in srgb, var(--overlay-notification-accent) 16%, transparent);
|
||||||
|
color: var(--overlay-notification-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.info .overlay-notification-icon::before {
|
||||||
|
content: 'i';
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.success .overlay-notification-icon::before {
|
||||||
|
content: '\2713';
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.warning .overlay-notification-icon::before {
|
||||||
|
content: '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.error .overlay-notification-icon::before {
|
||||||
|
content: '\2715';
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.progress .overlay-notification-icon::before {
|
||||||
|
content: '';
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--overlay-notification-accent) 28%, transparent);
|
||||||
|
border-top-color: var(--overlay-notification-accent);
|
||||||
|
animation: overlay-notification-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-content {
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-title {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-action {
|
||||||
|
min-height: 27px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 35%, var(--ctp-surface2));
|
||||||
|
background: color-mix(in srgb, var(--overlay-notification-accent) 12%, var(--ctp-surface0));
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 120ms ease,
|
||||||
|
border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-action:hover {
|
||||||
|
border-color: var(--overlay-notification-accent);
|
||||||
|
background: color-mix(in srgb, var(--overlay-notification-accent) 24%, var(--ctp-surface0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-close {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
align-self: start;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-close:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlay-notification-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlay-notification-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type RendererDom = {
|
|||||||
subtitleRoot: HTMLElement;
|
subtitleRoot: HTMLElement;
|
||||||
subtitleContainer: HTMLElement;
|
subtitleContainer: HTMLElement;
|
||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
|
overlayNotificationStack: HTMLDivElement;
|
||||||
controllerStatusToast: HTMLDivElement;
|
controllerStatusToast: HTMLDivElement;
|
||||||
overlayErrorToast: HTMLDivElement;
|
overlayErrorToast: HTMLDivElement;
|
||||||
secondarySubContainer: HTMLElement;
|
secondarySubContainer: HTMLElement;
|
||||||
@@ -132,6 +133,7 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||||
|
overlayNotificationStack: getRequiredElement<HTMLDivElement>('overlayNotificationStack'),
|
||||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { renderControl } from './settings-controls';
|
||||||
|
import type { ConfigSettingsField } from '../types/settings';
|
||||||
|
|
||||||
|
class FakeOption {
|
||||||
|
value = '';
|
||||||
|
textContent: string | null = null;
|
||||||
|
selected = false;
|
||||||
|
className = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeSelect {
|
||||||
|
value = '';
|
||||||
|
className = '';
|
||||||
|
options: FakeOption[] = [];
|
||||||
|
private readonly listeners = new Map<string, Array<() => void>>();
|
||||||
|
|
||||||
|
append(...children: FakeOption[]): void {
|
||||||
|
for (const child of children) {
|
||||||
|
this.options.push(child);
|
||||||
|
if (child.selected) {
|
||||||
|
this.value = child.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: () => void): void {
|
||||||
|
const listeners = this.listeners.get(type) ?? [];
|
||||||
|
listeners.push(listener);
|
||||||
|
this.listeners.set(type, listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event: Event): boolean {
|
||||||
|
for (const listener of this.listeners.get(event.type) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDocumentStub(): () => void {
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = {
|
||||||
|
createElement(tagName: string) {
|
||||||
|
return tagName === 'select' ? new FakeSelect() : new FakeOption();
|
||||||
|
},
|
||||||
|
} as unknown as Document;
|
||||||
|
return () => {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectField(): ConfigSettingsField {
|
||||||
|
return {
|
||||||
|
id: 'updates.notificationType',
|
||||||
|
label: 'Notification Type',
|
||||||
|
description: 'How SubMiner announces available updates.',
|
||||||
|
configPath: 'updates.notificationType',
|
||||||
|
category: 'tracking-app',
|
||||||
|
section: 'Updates',
|
||||||
|
control: 'select',
|
||||||
|
defaultValue: 'system',
|
||||||
|
enumValues: ['overlay', 'system', 'both', 'none'],
|
||||||
|
restartBehavior: 'restart',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('select controls show config-only current values without offering them otherwise', () => {
|
||||||
|
const restoreDocument = installDocumentStub();
|
||||||
|
const updates: Array<{ path: string; value: unknown }> = [];
|
||||||
|
try {
|
||||||
|
const control = renderControl(createSelectField(), {
|
||||||
|
valueForField: () => 'osd-system',
|
||||||
|
valueForPath: () => undefined,
|
||||||
|
updateDraft: (path, value) => updates.push({ path, value }),
|
||||||
|
resetDraftPath: () => {},
|
||||||
|
setFieldError: () => {},
|
||||||
|
}) as HTMLSelectElement;
|
||||||
|
|
||||||
|
assert.equal(control.value, 'osd-system');
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(control.options).map((option) => option.value),
|
||||||
|
['osd-system', 'overlay', 'system', 'both', 'none'],
|
||||||
|
);
|
||||||
|
|
||||||
|
control.value = 'overlay';
|
||||||
|
control.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
assert.deepEqual(updates, [{ path: 'updates.notificationType', value: 'overlay' }]);
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -216,7 +216,15 @@ export function renderControl(
|
|||||||
|
|
||||||
if (field.control === 'select') {
|
if (field.control === 'select') {
|
||||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||||
for (const enumValue of field.enumValues ?? []) {
|
const enumValues = field.enumValues ?? [];
|
||||||
|
if (typeof value === 'string' && value.length > 0 && !enumValues.includes(value)) {
|
||||||
|
const option = createElement('option') as HTMLOptionElement;
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = `${value} (config file only)`;
|
||||||
|
option.selected = true;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
for (const enumValue of enumValues) {
|
||||||
const option = createElement('option') as HTMLOptionElement;
|
const option = createElement('option') as HTMLOptionElement;
|
||||||
option.value = enumValue;
|
option.value = enumValue;
|
||||||
option.textContent = enumValue;
|
option.textContent = enumValue;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const IPC_CHANNELS = {
|
|||||||
reportOverlayContentBounds: 'overlay-content-bounds:report',
|
reportOverlayContentBounds: 'overlay-content-bounds:report',
|
||||||
reportOverlayInteractive: 'overlay-interactive:report',
|
reportOverlayInteractive: 'overlay-interactive:report',
|
||||||
overlayModalOpened: 'overlay:modal-opened',
|
overlayModalOpened: 'overlay:modal-opened',
|
||||||
|
overlayNotificationAction: 'overlay:notification-action',
|
||||||
toggleStatsOverlay: 'stats:toggle-overlay',
|
toggleStatsOverlay: 'stats:toggle-overlay',
|
||||||
markActiveVideoWatched: 'immersion:mark-active-video-watched',
|
markActiveVideoWatched: 'immersion:mark-active-video-watched',
|
||||||
dispatchSessionAction: 'session-action:dispatch',
|
dispatchSessionAction: 'session-action:dispatch',
|
||||||
@@ -144,6 +145,7 @@ export const IPC_CHANNELS = {
|
|||||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
|
overlayNotification: 'overlay:notification',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: boolean;
|
||||||
|
osdMessages: boolean;
|
||||||
texthookerEnabled: boolean;
|
texthookerEnabled: boolean;
|
||||||
aniskipEnabled: boolean;
|
aniskipEnabled: boolean;
|
||||||
aniskipButtonKey: string;
|
aniskipButtonKey: string;
|
||||||
@@ -44,6 +45,7 @@ export function buildSubminerPluginRuntimeScriptOptParts(
|
|||||||
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
|
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
|
||||||
runtimeConfig.autoStartPauseUntilReady,
|
runtimeConfig.autoStartPauseUntilReady,
|
||||||
)}`,
|
)}`,
|
||||||
|
`subminer-osd_messages=${boolScriptOpt(runtimeConfig.osdMessages)}`,
|
||||||
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
|
||||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
||||||
`subminer-aniskip_button_key=${aniskipButtonKey}`,
|
`subminer-aniskip_button_key=${aniskipButtonKey}`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './types/anki';
|
export * from './types/anki';
|
||||||
export * from './types/config';
|
export * from './types/config';
|
||||||
export * from './types/integrations';
|
export * from './types/integrations';
|
||||||
|
export * from './types/notification';
|
||||||
export * from './types/runtime';
|
export * from './types/runtime';
|
||||||
export * from './types/runtime-options';
|
export * from './types/runtime-options';
|
||||||
export * from './types/session-bindings';
|
export * from './types/session-bindings';
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { AiFeatureConfig } from './integrations';
|
import type { AiFeatureConfig } from './integrations';
|
||||||
|
import type { NotificationType } from './notification';
|
||||||
import type { NPlusOneMatchMode } from './subtitle';
|
import type { NPlusOneMatchMode } from './subtitle';
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
@@ -94,7 +95,7 @@ export interface AnkiConnectConfig {
|
|||||||
overwriteImage?: boolean;
|
overwriteImage?: boolean;
|
||||||
mediaInsertMode?: 'append' | 'prepend';
|
mediaInsertMode?: 'append' | 'prepend';
|
||||||
highlightWord?: boolean;
|
highlightWord?: boolean;
|
||||||
notificationType?: 'osd' | 'system' | 'both' | 'none';
|
notificationType?: NotificationType;
|
||||||
autoUpdateNewCards?: boolean;
|
autoUpdateNewCards?: boolean;
|
||||||
};
|
};
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|||||||
+9
-2
@@ -36,6 +36,7 @@ import type {
|
|||||||
SubtitleSidebarConfig,
|
SubtitleSidebarConfig,
|
||||||
SubtitleStyleConfig,
|
SubtitleStyleConfig,
|
||||||
} from './subtitle';
|
} from './subtitle';
|
||||||
|
import type { NotificationType, OverlayNotificationPosition } from './notification';
|
||||||
|
|
||||||
export interface WebSocketConfig {
|
export interface WebSocketConfig {
|
||||||
enabled?: boolean | 'auto';
|
enabled?: boolean | 'auto';
|
||||||
@@ -83,7 +84,7 @@ export interface StartupWarmupsConfig {
|
|||||||
jellyfinRemoteSession?: boolean;
|
jellyfinRemoteSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateNotificationType = 'system' | 'osd' | 'both' | 'none';
|
export type UpdateNotificationType = NotificationType;
|
||||||
export type UpdateChannel = 'stable' | 'prerelease';
|
export type UpdateChannel = 'stable' | 'prerelease';
|
||||||
|
|
||||||
export interface UpdatesConfig {
|
export interface UpdatesConfig {
|
||||||
@@ -93,6 +94,10 @@ export interface UpdatesConfig {
|
|||||||
channel?: UpdateChannel;
|
channel?: UpdateChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationsConfig {
|
||||||
|
overlayPosition?: OverlayNotificationPosition;
|
||||||
|
}
|
||||||
|
|
||||||
export type LogRotation = number;
|
export type LogRotation = number;
|
||||||
|
|
||||||
export interface LogFilesConfig {
|
export interface LogFilesConfig {
|
||||||
@@ -149,6 +154,7 @@ export interface Config {
|
|||||||
immersionTracking?: ImmersionTrackingConfig;
|
immersionTracking?: ImmersionTrackingConfig;
|
||||||
stats?: StatsConfig;
|
stats?: StatsConfig;
|
||||||
updates?: UpdatesConfig;
|
updates?: UpdatesConfig;
|
||||||
|
notifications?: NotificationsConfig;
|
||||||
logging?: {
|
logging?: {
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
rotation?: LogRotation;
|
rotation?: LogRotation;
|
||||||
@@ -247,7 +253,7 @@ export interface ResolvedConfig {
|
|||||||
overwriteImage: boolean;
|
overwriteImage: boolean;
|
||||||
mediaInsertMode: 'append' | 'prepend';
|
mediaInsertMode: 'append' | 'prepend';
|
||||||
highlightWord: boolean;
|
highlightWord: boolean;
|
||||||
notificationType: 'osd' | 'system' | 'both' | 'none';
|
notificationType: NotificationType;
|
||||||
autoUpdateNewCards: boolean;
|
autoUpdateNewCards: boolean;
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -379,6 +385,7 @@ export interface ResolvedConfig {
|
|||||||
autoOpenBrowser: boolean;
|
autoOpenBrowser: boolean;
|
||||||
};
|
};
|
||||||
updates: Required<UpdatesConfig>;
|
updates: Required<UpdatesConfig>;
|
||||||
|
notifications: Required<NotificationsConfig>;
|
||||||
logging: {
|
logging: {
|
||||||
level: 'debug' | 'info' | 'warn' | 'error';
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
rotation: LogRotation;
|
rotation: LogRotation;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export const SETTINGS_NOTIFICATION_TYPE_VALUES = ['overlay', 'system', 'both', 'none'] as const;
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPE_VALUES = [
|
||||||
|
...SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||||
|
'osd',
|
||||||
|
'osd-system',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OVERLAY_NOTIFICATION_POSITION_VALUES = ['top-left', 'top', 'top-right'] as const;
|
||||||
|
|
||||||
|
export type SettingsNotificationType = (typeof SETTINGS_NOTIFICATION_TYPE_VALUES)[number];
|
||||||
|
export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
||||||
|
export type OverlayNotificationPosition = (typeof OVERLAY_NOTIFICATION_POSITION_VALUES)[number];
|
||||||
|
|
||||||
|
export type OverlayNotificationVariant = 'info' | 'success' | 'warning' | 'error' | 'progress';
|
||||||
|
|
||||||
|
export interface OverlayNotificationAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlayNotificationPayload {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
variant?: OverlayNotificationVariant;
|
||||||
|
position?: OverlayNotificationPosition;
|
||||||
|
persistent?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
actions?: OverlayNotificationAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverlayNotificationDismissPayload {
|
||||||
|
id: string;
|
||||||
|
dismiss: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayNotificationEventPayload =
|
||||||
|
| OverlayNotificationPayload
|
||||||
|
| OverlayNotificationDismissPayload;
|
||||||
|
|
||||||
|
export function isNotificationType(value: unknown): value is NotificationType {
|
||||||
|
return typeof value === 'string' && NOTIFICATION_TYPE_VALUES.includes(value as NotificationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOverlayNotificationPosition(
|
||||||
|
value: unknown,
|
||||||
|
): value is OverlayNotificationPosition {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
OVERLAY_NOTIFICATION_POSITION_VALUES.includes(value as OverlayNotificationPosition)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
RuntimeOptionState,
|
RuntimeOptionState,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
} from './runtime-options';
|
} from './runtime-options';
|
||||||
|
import type { OverlayNotificationEventPayload } from './notification';
|
||||||
|
|
||||||
export interface WindowGeometry {
|
export interface WindowGeometry {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -405,6 +406,8 @@ export interface ElectronAPI {
|
|||||||
getOverlayLayer: () => 'visible' | 'modal' | null;
|
getOverlayLayer: () => 'visible' | 'modal' | null;
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||||
onOverlayPointerRecoveryRequested: (callback: () => void) => void;
|
onOverlayPointerRecoveryRequested: (callback: () => void) => void;
|
||||||
|
onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void;
|
||||||
|
sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void;
|
||||||
onVisibility: (callback: (visible: boolean) => void) => void;
|
onVisibility: (callback: (visible: boolean) => void) => void;
|
||||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
||||||
getOverlayVisibility: () => Promise<boolean>;
|
getOverlayVisibility: () => Promise<boolean>;
|
||||||
|
|||||||
Reference in New Issue
Block a user