diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md new file mode 100644 index 00000000..f8b4e65c --- /dev/null +++ b/changes/overlay-notifications.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 52ba0736..e2334ef5 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -172,10 +172,19 @@ "updates": { "enabled": true, // Run automatic update checks in the background. Values: true | false "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 }, // 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 // 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 "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 - "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 }, // Behavior setting. "nPlusOne": { diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 9fba9a63..bbecc0cd 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -216,11 +216,13 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) "overwriteImage": true, // replace existing image, or append "mediaInsertMode": "append", // "append" or "prepend" to field content "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. ## AI Translation @@ -351,7 +353,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead "overwriteImage": true, "mediaInsertMode": "append", "autoUpdateNewCards": true, - "notificationType": "osd", + "notificationType": "overlay", }, "ai": { "enabled": false, diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0f268f91..9932530f 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -158,6 +158,7 @@ The configuration file includes several main sections: - [**MPV Launcher**](#mpv-launcher) - mpv executable path, profile, and window launch mode - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading - [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing +- [**Notifications**](#notifications) - Overlay notification placement ## Core Settings @@ -202,12 +203,32 @@ Configure automatic update checks and update notifications: } ``` -| Option | Values | Description | -| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `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`. | -| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. | -| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | +| Option | Values | Description | +| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `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`. | +| `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. | + +`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 @@ -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. -| Option | Values | Description | -| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `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) | -| `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.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`) | -| `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. | -| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `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`) | -| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `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.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.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.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.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.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.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.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.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.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.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`). | -| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | -| `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 | -| `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`) | +| Option | Values | Description | +| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `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) | +| `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.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`) | +| `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. | +| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `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`) | +| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `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.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.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.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.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.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.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.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.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.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.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`). | +| `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`) | +| `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`. | +| `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. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 52ba0736..e2334ef5 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -172,10 +172,19 @@ "updates": { "enabled": true, // Run automatic update checks in the background. Values: true | false "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 }, // 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 // 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 "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 - "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 }, // Behavior setting. "nPlusOne": { diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 33035448..24c55fb2 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -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** -`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 diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index bb9ab227..eeb0c38e 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -45,6 +45,7 @@ function createContext(overrides: Partial = {}): Launche autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, appPath: '/tmp/subminer.app', diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index a7af0120..fb3cb0d1 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -82,6 +82,7 @@ function createContext(): LauncherCommandContext { autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, appPath: '/tmp/SubMiner.AppImage', @@ -207,6 +208,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner', autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, + osdMessages: false, texthookerEnabled: false, }; const appPath = context.appPath ?? ''; @@ -268,6 +270,7 @@ test('plugin auto-start playback attaches a warm background app through the laun autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: true, }; const calls: string[] = []; @@ -335,6 +338,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: true, }; let availabilityConfigDir: string | undefined; @@ -395,6 +399,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: true, }; const calls: string[] = []; diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index b9ed4419..40a86188 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -125,6 +125,11 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => { test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => { const parsed = parsePluginRuntimeConfigFromMainConfig({ auto_start_overlay: false, + ankiConnect: { + behavior: { + notificationType: 'osd-system', + }, + }, texthooker: { launchAtStartup: false, }, @@ -142,16 +147,30 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi assert.equal(parsed.autoStart, true); assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartPauseUntilReady, true); + assert.equal(parsed.osdMessages, true); assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage'); assert.equal(parsed.texthookerEnabled, false); }); +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', () => { const parsed = parsePluginRuntimeConfigFromMainConfig(null); assert.equal(parsed.autoStart, true); assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartPauseUntilReady, true); + assert.equal(parsed.osdMessages, false); assert.equal(parsed.texthookerEnabled, false); }); @@ -165,6 +184,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: true, + osdMessages: true, texthookerEnabled: false, }, '/fallback/SubMiner.AppImage', @@ -176,6 +196,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-osd_messages=yes', 'subminer-texthooker_enabled=no', ], ); @@ -191,6 +212,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, '/fallback/SubMiner.AppImage', @@ -202,6 +224,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-osd_messages=no', 'subminer-texthooker_enabled=no', ], ); diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index 2657d814..4f5f4218 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -16,10 +16,9 @@ function booleanOrDefault(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } -function nonEmptyStringOrDefault(value: unknown, fallback: string): string { - if (typeof value !== 'string') return fallback; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : fallback; +function pluginOsdMessagesFromNotificationType(root: Record | null): boolean { + const notificationType = rootObject(rootObject(root, 'ankiConnect'), 'behavior').notificationType; + return notificationType === 'osd' || notificationType === 'osd-system'; } function validBackendOrDefault(value: unknown, fallback: Backend): Backend { @@ -53,6 +52,7 @@ export function parsePluginRuntimeConfigFromMainConfig( autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), + osdMessages: pluginOsdMessagesFromNotificationType(root), texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false), }; } @@ -70,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig log( 'debug', 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}`, + `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}`, ); return parsed; } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 9beb6b1c..a235825d 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -385,6 +385,7 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate', autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, }), diff --git a/launcher/types.ts b/launcher/types.ts index be8690c8..6c804a4c 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -207,6 +207,7 @@ export interface PluginRuntimeConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + osdMessages: boolean; texthookerEnabled: boolean; } diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 6fedae84..63685887 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -2,6 +2,7 @@ local M = {} function M.create(ctx) local mp = ctx.mp + local opts = ctx.opts local process = ctx.process local hover = ctx.hover local ui = ctx.ui @@ -49,7 +50,9 @@ function M.create(ctx) hover.handle_hover_message(payload_json) end) 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) mp.register_script_message("subminer-reload-session-bindings", function() ctx.session_bindings.reload_bindings() diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 03bf4b37..49798e5b 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -406,6 +406,40 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode 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 () => { const collaborator = createFieldGroupingMergeCollaborator(); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 5a890c5c..ebad7af2 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -29,6 +29,7 @@ import { } from './types/anki'; import { AiConfig } from './types/integrations'; import { MpvClient } from './types/runtime'; +import type { NotificationType, OverlayNotificationPayload } from './types/notification'; import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { @@ -130,6 +131,8 @@ export class AnkiIntegration { private osdCallback: ((text: string) => void) | null = null; private notificationCallback: ((title: string, options: NotificationOptions) => void) | null = null; + private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null = + null; private updateInProgress = false; private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private parseWarningKeys = new Set(); @@ -166,6 +169,7 @@ export class AnkiIntegration { knownWordCacheStatePath?: string, aiConfig: AiConfig = {}, recordCardsMined?: (count: number, noteIds?: number[]) => void, + overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void, ) { this.config = normalizeAnkiIntegrationConfig(config); this.aiConfig = { ...aiConfig }; @@ -175,6 +179,7 @@ export class AnkiIntegration { this.mpvClient = mpvClient; this.osdCallback = osdCallback || null; this.notificationCallback = notificationCallback || null; + this.overlayNotificationCallback = overlayNotificationCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null; this.recordCardsMinedCallback = recordCardsMined ?? null; this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); @@ -335,7 +340,7 @@ export class AnkiIntegration { options, ), }, - showOsdNotification: (text: string) => this.showOsdNotification(text), + showOsdNotification: (text: string) => this.showStatusNotification(text), showUpdateResult: (message: string, success: boolean) => this.showUpdateResult(message, success), showStatusNotification: (message: string) => this.showStatusNotification(message), @@ -387,7 +392,7 @@ export class AnkiIntegration { getDeck: () => this.config.deck, withUpdateProgress: (initialMessage: string, action: () => Promise) => this.withUpdateProgress(initialMessage, action), - showOsdNotification: (text: string) => this.showOsdNotification(text), + showOsdNotification: (text: string) => this.showStatusNotification(text), findNotes: async (query, options) => (await this.client.findNotes(query, options)) as number[], notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[], @@ -463,7 +468,7 @@ export class AnkiIntegration { consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), showNotification: (noteId, label) => this.showNotification(noteId, label), - showOsdNotification: (message) => this.showOsdNotification(message), + showOsdNotification: (message) => this.showStatusNotification(message), beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage), endUpdateProgress: () => this.endUpdateProgress(), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), @@ -510,7 +515,7 @@ export class AnkiIntegration { }, showStatusNotification: (message) => this.showStatusNotification(message), 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)), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), truncateSentence: (sentence) => this.truncateSentence(sentence), @@ -860,10 +865,13 @@ export class AnkiIntegration { private showStatusNotification(message: string): void { showStatusNotification(message, { - getNotificationType: () => this.config.behavior?.notificationType, + getNotificationType: () => this.getNotificationType(), showOsd: (text: string) => { this.showOsdNotification(text); }, + showOverlayNotification: (payload) => { + this.overlayNotificationCallback?.(payload); + }, showSystemNotification: (title: string, options: NotificationOptions) => { if (this.notificationCallback) { 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 { + 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) => { this.showOsdNotification(text); }); } private endUpdateProgress(): void { + if (!this.shouldUseOsdNotifications()) { + return; + } endUpdateProgress(this.uiFeedbackState, (timer) => { clearInterval(timer); }); } private clearUpdateProgress(): void { + if (!this.shouldUseOsdNotifications()) { + return; + } clearUpdateProgress(this.uiFeedbackState, (timer) => { clearInterval(timer); }); @@ -894,6 +934,23 @@ export class AnkiIntegration { initialMessage: string, action: () => Promise, ): Promise { + 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( this.uiFeedbackState, { @@ -1017,15 +1074,28 @@ export class AnkiIntegration { ? `Updated card: ${label} (${errorSuffix})` : `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); } else { 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; if (this.mpvClient && this.mpvClient.currentVideoPath) { diff --git a/src/anki-integration/ui-feedback.test.ts b/src/anki-integration/ui-feedback.test.ts index b4c2d7e4..5bbcaec7 100644 --- a/src/anki-integration/ui-feedback.test.ts +++ b/src/anki-integration/ui-feedback.test.ts @@ -1,9 +1,10 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; +import test from 'node:test'; import { beginUpdateProgress, createUiFeedbackState, showProgressTick, + showStatusNotification, showUpdateResult, } from './ui-feedback'; @@ -65,3 +66,38 @@ test('showUpdateResult renders failed updates with an x marker', () => { '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']); +}); diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts index f9f53d64..7614fea4 100644 --- a/src/anki-integration/ui-feedback.ts +++ b/src/anki-integration/ui-feedback.ts @@ -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 { progressDepth: number; @@ -13,8 +14,9 @@ export interface UiFeedbackResult { } export interface UiFeedbackNotificationContext { - getNotificationType: () => string | undefined; + getNotificationType: () => NotificationType | undefined; showOsd: (text: string) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showSystemNotification: (title: string, options: NotificationOptions) => void; } @@ -36,13 +38,29 @@ export function showStatusNotification( message: string, context: UiFeedbackNotificationContext, ): 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); } - if (type === 'system' || type === 'both') { + if (type === 'system' || type === 'both' || type === 'osd-system') { context.showSystemNotification('SubMiner', { body: message }); } } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8ae18f0a..4c89fa76 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -172,7 +172,7 @@ test('parses updates config and warns on invalid values', () => { "updates": { "enabled": false, "checkIntervalHours": 6, - "notificationType": "both", + "notificationType": "osd-system", "channel": "prerelease" } }`, @@ -182,7 +182,7 @@ test('parses updates config and warns on invalid values', () => { const validService = new ConfigService(validDir); assert.equal(validService.getConfig().updates.enabled, false); 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'); 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')); }); +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', () => { const dir = makeTempDir(); const configPath = path.join(dir, 'config.jsonc'); @@ -2750,7 +2813,7 @@ test('template generator includes known keys', () => { ); assert.match( 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( output, diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 576ccbcf..fb9ba3bf 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -34,6 +34,7 @@ const { subsync, startupWarmups, updates, + notifications, auto_start_overlay, } = CORE_DEFAULT_CONFIG; const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = @@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { subsync, startupWarmups, updates, + notifications, subtitleStyle, subtitleSidebar, auto_start_overlay, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index dcc0c730..167ed03e 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -15,6 +15,7 @@ export const CORE_DEFAULT_CONFIG: Pick< | 'subsync' | 'startupWarmups' | 'updates' + | 'notifications' | 'auto_start_overlay' > = { subtitlePosition: { yPercent: 10 }, @@ -129,5 +130,8 @@ export const CORE_DEFAULT_CONFIG: Pick< notificationType: 'system', channel: 'stable', }, + notifications: { + overlayPosition: 'top-right', + }, auto_start_overlay: true, }; diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 4e46a255..d81f312e 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -67,7 +67,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< overwriteImage: true, mediaInsertMode: 'append', highlightWord: true, - notificationType: 'osd', + notificationType: 'overlay', autoUpdateNewCards: true, }, nPlusOne: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 205b28b7..3fa71814 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -1,4 +1,9 @@ import { ResolvedConfig } from '../../types/config'; +import { + NOTIFICATION_TYPE_VALUES, + OVERLAY_NOTIFICATION_POSITION_VALUES, + SETTINGS_NOTIFICATION_TYPE_VALUES, +} from '../../types/notification'; import { ConfigOptionRegistryEntry } from './shared'; export function buildCoreConfigOptionRegistry( @@ -484,9 +489,11 @@ export function buildCoreConfigOptionRegistry( { path: 'updates.notificationType', kind: 'enum', - enumValues: ['system', 'osd', 'both', 'none'], + enumValues: NOTIFICATION_TYPE_VALUES, + settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES, 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', @@ -495,6 +502,13 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.updates.channel, 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', kind: 'number', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 720c60ae..32e89832 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -1,5 +1,9 @@ import { ResolvedConfig } from '../../types/config'; 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'; export function buildIntegrationConfigOptionRegistry( @@ -158,9 +162,11 @@ export function buildIntegrationConfigOptionRegistry( { path: 'ankiConnect.behavior.notificationType', kind: 'enum', - enumValues: ['osd', 'system', 'both', 'none'], + enumValues: NOTIFICATION_TYPE_VALUES, + settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES, 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', diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index adceef26..2b6c13b4 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -28,6 +28,7 @@ export interface ConfigOptionRegistryEntry { defaultValue: unknown; description: string; enumValues?: readonly string[]; + settingsEnumValues?: readonly string[]; runtime?: RuntimeOptionRegistryEntry; } diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 3c312aff..943d543b 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -63,6 +63,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], 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', description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 2db4a7c6..e1e55b5d 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,7 +1,12 @@ import { DEFAULT_CONFIG } from '../definitions'; import type { ResolveContext } from './context'; +import { isNotificationType, type NotificationType } from '../../types/notification'; 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 { if (!isObject(context.src.ankiConnect)) { return; @@ -42,6 +47,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { 'notificationType', 'autoUpdateNewCards', ]); + const hasOwn = (obj: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(obj, key); const { 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)) { const lapisEnabled = asBoolean(ac.isLapis.enabled); if (lapisEnabled !== undefined) { @@ -289,8 +312,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { } const legacy = ac as Record; - const hasOwn = (obj: Record, key: string): boolean => - Object.prototype.hasOwnProperty.call(obj, key); const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => { const parsed = asNumber(value); 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 => { 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 = ( key: string, 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, - "Expected 'osd', 'system', 'both', or 'none'.", + "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", ); } if (!hasOwn(behavior, 'autoUpdateNewCards')) { diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 5231583b..382b61a0 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -1,5 +1,6 @@ import { ResolveContext } from './context'; import { applyControllerConfig } from './controller'; +import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification'; import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyCoreDomainConfig(context: ResolveContext): void { @@ -194,19 +195,14 @@ export function applyCoreDomainConfig(context: ResolveContext): void { } const notificationType = asString(src.updates.notificationType); - if ( - notificationType === 'system' || - notificationType === 'osd' || - notificationType === 'both' || - notificationType === 'none' - ) { + if (isNotificationType(notificationType)) { resolved.updates.notificationType = notificationType; } else if (src.updates.notificationType !== undefined) { warn( 'updates.notificationType', src.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; } } + + 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.', + ); + } + } } diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index a24573cb..ebddab3e 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -151,6 +151,7 @@ const SECTION_ORDER = new Map( 'Startup warmups', 'Logging', 'Updates', + 'Notifications', 'Immersion tracking', ].map((section, index) => [section, index]), ); @@ -411,6 +412,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s ) { return { category: 'behavior', section: 'Playback Behavior' }; } + if (path.startsWith('notifications.')) { + return { category: 'behavior', section: 'Notifications' }; + } if (path === 'mpv.aniskipButtonKey') { return { category: 'input', section: 'Overlay Shortcuts' }; } @@ -478,6 +482,7 @@ function topSection(path: string): string { mpv: 'mpv Playback', stats: 'Stats dashboard', startupWarmups: 'Startup warmups', + notifications: 'Notifications', subsync: 'Subtitle Sync', texthooker: 'Texthooker', updates: 'Updates', @@ -687,6 +692,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { path === 'logging.level' || path === 'logging.rotation' || pathStartsWith(path, 'logging.files') || + pathStartsWith(path, 'notifications') || path === 'youtube.primarySubLanguages' || pathStartsWith(path, 'jimaku') || pathStartsWith(path, 'subsync') @@ -710,7 +716,9 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField { ...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}), control: controlForPath(leaf.path, leaf.value), defaultValue: leaf.value, - ...(option?.enumValues ? { enumValues: option.enumValues } : {}), + ...(option?.settingsEnumValues || option?.enumValues + ? { enumValues: option.settingsEnumValues ?? option.enumValues } + : {}), restartBehavior: restartBehaviorForPath(leaf.path), advanced: leaf.path.startsWith('controller.') || diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index d2cbfd89..625afeab 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -10,6 +10,7 @@ import { JimakuMediaInfo, KikuFieldGroupingChoice, KikuFieldGroupingRequestData, + OverlayNotificationPayload, } from '../../types'; import { sortJimakuFiles } from '../../jimaku/utils'; import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc'; @@ -40,6 +41,7 @@ export interface AnkiJimakuIpcRuntimeOptions { setAnkiIntegration: (integration: AnkiIntegration | null) => void; getKnownWordCacheStatePath: () => string; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -103,6 +105,8 @@ export function registerAnkiJimakuIpcRuntime( options.createFieldGroupingCallback(), options.getKnownWordCacheStatePath(), mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, + undefined, + options.showOverlayNotification, ); integration.start(); options.setAnkiIntegration(integration); diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index 9326d9e9..cf175960 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -6,6 +6,7 @@ function createOptions(overrides: Partial[1] = { specialCommands: { SUBSYNC_TRIGGER: '__subsync-trigger', @@ -38,6 +39,9 @@ function createOptions(overrides: Partial { osd.push(text); }, + showPlaybackFeedback: (text) => { + playbackFeedback.push(text); + }, mpvReplaySubtitle: () => { calls.push('replay'); }, @@ -55,7 +59,7 @@ function createOptions(overrides: Partial true, ...overrides, }; - return { options, calls, sentCommands, osd }; + return { options, calls, sentCommands, osd, playbackFeedback }; } test('handleMpvCommandFromIpc forwards regular mpv commands', () => { @@ -65,41 +69,53 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => { assert.deepEqual(osd, []); }); -test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => { - const { options, sentCommands, osd } = createOptions(); +test('handleMpvCommandFromIpc routes show-text through playback feedback', () => { + 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); await new Promise((resolve) => setImmediate(resolve)); 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 () => { - const { options, sentCommands, osd } = createOptions({ +test('handleMpvCommandFromIpc emits resolved feedback for primary subtitle track keybinding proxies', async () => { + const { options, sentCommands, osd, playbackFeedback } = createOptions({ resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)', }); handleMpvCommandFromIpc(['cycle', 'sid'], options); await new Promise((resolve) => setImmediate(resolve)); 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 () => { - const { options, sentCommands, osd } = createOptions({ +test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle track keybinding proxies', async () => { + const { options, sentCommands, osd, playbackFeedback } = createOptions({ resolveProxyCommandOsd: async () => 'Secondary subtitle track: External #8 - English Commentary', }); handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options); await new Promise((resolve) => setImmediate(resolve)); 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 () => { - const { options, sentCommands, osd } = createOptions(); +test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => { + const { options, sentCommands, osd, playbackFeedback } = createOptions(); handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); await new Promise((resolve) => setImmediate(resolve)); 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', () => { diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 9dbac091..9099bf21 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -25,6 +25,7 @@ export interface HandleMpvCommandFromIpcOptions { openPlaylistBrowser: () => void | Promise; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; mpvReplaySubtitle: () => void; mpvPlayNextSubtitle: () => void; shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; @@ -68,13 +69,14 @@ function showResolvedProxyCommandOsd( ): void { const template = resolveProxyCommandOsdTemplate(command); if (!template) return; + const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd; const emit = async () => { try { const resolved = await options.resolveProxyCommandOsd?.(command); - options.showMpvOsd(resolved || template); + showFeedback(resolved || template); } catch { - options.showMpvOsd(template); + showFeedback(template); } }; @@ -142,6 +144,15 @@ export function handleMpvCommandFromIpc( 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 (first === options.specialCommands.REPLAY_SUBTITLE) { options.mpvReplaySubtitle(); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 9648d794..29c10944 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -6,6 +6,7 @@ import { AnkiConnectConfig, KikuFieldGroupingChoice, KikuFieldGroupingRequestData, + OverlayNotificationPayload, WindowGeometry, } from '../../types'; @@ -19,6 +20,7 @@ type CreateAnkiIntegrationArgs = { subtitleTimingTracker: unknown; mpvClient: { send?: (payload: { command: string[] }) => void }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -61,6 +63,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte args.createFieldGroupingCallback(), args.knownWordCacheStatePath, args.aiConfig, + undefined, + args.showOverlayNotification, ); } @@ -123,6 +127,7 @@ export function initializeOverlayRuntime( getAnkiIntegration?: () => unknown | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -156,6 +161,7 @@ export function initializeOverlayAnkiIntegration(options: { getAnkiIntegration?: () => unknown | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -191,6 +197,7 @@ export function initializeOverlayAnkiIntegration(options: { subtitleTimingTracker, mpvClient, showDesktopNotification: options.showDesktopNotification, + showOverlayNotification: options.showOverlayNotification, createFieldGroupingCallback: options.createFieldGroupingCallback, knownWordCacheStatePath: options.getKnownWordCacheStatePath(), }); diff --git a/src/main-entry-launch-config.ts b/src/main-entry-launch-config.ts index 47f87f30..173aad47 100644 --- a/src/main-entry-launch-config.ts +++ b/src/main-entry-launch-config.ts @@ -16,7 +16,10 @@ export interface ConfiguredWindowsMpvLaunch { } export function buildWindowsMpvPluginRuntimeConfig( - config: Pick, + config: Pick< + ResolvedConfig, + 'ankiConnect' | 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker' + >, ): SubminerPluginRuntimeScriptOptConfig { return { socketPath: config.mpv.socketPath, @@ -27,6 +30,9 @@ export function buildWindowsMpvPluginRuntimeConfig( autoStart: config.mpv.autoStartSubMiner, autoStartVisibleOverlay: config.auto_start_overlay, autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, + osdMessages: + config.ankiConnect.behavior.notificationType === 'osd' || + config.ankiConnect.behavior.notificationType === 'osd-system', texthookerEnabled: config.texthooker.launchAtStartup, }; } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 01cb957e..f876c850 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -325,6 +325,7 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner, autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay, autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady, + osdMessages: false, texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup, }); } finally { @@ -377,6 +378,7 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script autoStart: false, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, + osdMessages: false, texthookerEnabled: true, }); } finally { diff --git a/src/main.ts b/src/main.ts index 8df702bb..ac5b48b3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -140,6 +140,9 @@ import type { SubtitleData, SubtitleMiningContext, SubtitlePosition, + OverlayNotificationPayload, + OverlayNotificationEventPayload, + NotificationType, UpdateChannel, WindowGeometry, } from './types'; @@ -602,6 +605,12 @@ import { import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy'; import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; 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 { runUpdateCliCommand, @@ -1234,7 +1243,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ mainWindow.webContents.focus(); } }, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text), reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message), notifyPrimarySubtitleLoaded: () => youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(), @@ -1469,6 +1478,9 @@ function getMpvPluginRuntimeConfig() { autoStart: config.mpv.autoStartSubMiner, autoStartVisibleOverlay: config.auto_start_overlay, autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, + osdMessages: + config.ankiConnect.behavior.notificationType === 'osd' || + config.ankiConnect.behavior.notificationType === 'osd-system', texthookerEnabled: config.texthooker.launchAtStartup, }; } @@ -1714,7 +1726,7 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain setSubsyncInProgress: (inProgress) => { appState.subsyncInProgress = inProgress; }, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showSubsyncStatusNotification(text), openManualPicker: (payload) => { openOverlayHostedModalWithOsd( (deps) => openSubsyncManualModalRuntime(deps, payload), @@ -1736,7 +1748,10 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); const currentMediaTokenizationGate = createCurrentMediaTokenizationGate(); const startupOsdSequencer = createStartupOsdSequencer({ + getNotificationType: () => getConfiguredStatusNotificationType(), showOsd: (message) => showMpvOsd(message), + showOverlayNotification, + showDesktopNotification: (title, options) => showDesktopNotification(title, options), }); const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({ getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, @@ -1767,11 +1782,21 @@ function isYoutubePlaybackActiveNow(): boolean { } function reportYoutubeSubtitleFailure(message: string): void { - const type = getResolvedConfig().ankiConnect.behavior.notificationType; - if (type === 'osd' || type === 'both') { + const type = getConfiguredStatusNotificationType(); + if (type === 'none') { + return; + } + if (type === 'overlay' || type === 'both') { + showOverlayNotification({ + title: 'SubMiner', + body: message, + variant: 'warning', + }); + } + if (type === 'osd' || type === 'osd-system') { showMpvOsd(message); } - if (type === 'system' || type === 'both') { + if (type === 'system' || type === 'both' || type === 'osd-system') { try { showDesktopNotification('SubMiner', { body: message }); } catch { @@ -1782,13 +1807,22 @@ function reportYoutubeSubtitleFailure(message: string): void { async function openYoutubeTrackPickerFromPlayback(): Promise { if (youtubeFlowRuntime.hasActiveSession()) { - showMpvOsd('YouTube subtitle flow already in progress.'); + showConfiguredStatusNotification('YouTube subtitle flow already in progress.', { + title: 'YouTube subtitles', + variant: 'warning', + }); return; } const currentMediaPath = appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || ''; 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; } await youtubeFlowRuntime.openManualPicker({ @@ -2134,7 +2168,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( return windowTracker.isTargetWindowFocused(); }, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredStatusNotification(text), openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, @@ -2177,7 +2211,9 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => { const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( { + getNotificationType: () => getConfiguredStatusNotificationType(), showMpvOsd: (message) => showMpvOsd(message), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), }, ); @@ -2536,8 +2572,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt logWarn: (message) => logger.warn(message), onSyncStatus: (event) => { notifyCharacterDictionaryAutoSyncStatus(event, { - getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + getNotificationType: () => getConfiguredStatusNotificationType(), showOsd: (message) => showMpvOsd(message), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), startupOsdSequencer, }); @@ -2614,7 +2651,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( isMacOSPlatform: () => process.platform === 'darwin', isWindowsPlatform: () => process.platform === 'win32', showOverlayLoadingOsd: (message: string) => { - showMpvOsd(message); + showOverlayLoadingStatusNotification(message); }, hideNonNativeOverlayWhenTargetUnfocused: () => shouldRunLinuxOverlayZOrderKeepAlive() && @@ -3296,6 +3333,93 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { 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 = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, @@ -3386,12 +3510,12 @@ function openOverlayHostedModalWithOsd( void openModal(createOverlayHostedModalOpenDeps()) .then((opened) => { if (!opened) { - showMpvOsd(unavailableMessage); + showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' }); } }) .catch((error) => { logger.error(failureLogMessage, error); - showMpvOsd(unavailableMessage); + showConfiguredStatusNotification(unavailableMessage, { variant: 'error' }); }); } @@ -3422,7 +3546,7 @@ function openSessionHelpOverlay(): void { function openCharacterDictionaryManagerOverlay(): void { openCharacterDictionaryManagerWithConfigGate({ isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, - getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + getNotificationType: () => getConfiguredStatusNotificationType(), openManager: () => { openOverlayHostedModalWithOsd( openCharacterDictionaryManagerModalRuntime, @@ -3431,6 +3555,7 @@ function openCharacterDictionaryManagerOverlay(): void { ); }, showOsd: (message) => showMpvOsd(message), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), logWarn: (message, error) => logger.warn(message, error), }); @@ -3454,7 +3579,10 @@ function openControllerDebugOverlay(): void { function openPlaylistBrowser(): void { if (!appState.mpvClient?.connected) { - showMpvOsd('Playlist browser requires active playback.'); + showConfiguredStatusNotification('Playlist browser requires active playback.', { + title: 'Playlist browser', + variant: 'warning', + }); return; } openOverlayHostedModalWithOsd( @@ -3636,7 +3764,7 @@ const { void appState.jellyfinRemoteSession?.reportPlaying(payload); }, showMpvOsd: (text) => { - showMpvOsd(text); + showConfiguredStatusNotification(text, { title: 'Jellyfin' }); }, updateCurrentMediaTitle: (title) => { mediaRuntime.updateCurrentMediaTitle(title); @@ -3770,7 +3898,7 @@ const { }), logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), - showMpvOsd: (message) => showMpvOsd(message), + showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }), clearSetupWindow: () => { appState.jellyfinSetupWindow = null; }, @@ -3938,8 +4066,10 @@ const { registerSubminerProtocolClient, } = composeAnilistSetupHandlers({ notifyDeps: { + getNotificationType: () => getConfiguredStatusNotificationType(), hasMpvClient: () => Boolean(appState.mpvClient), - showMpvOsd: (message) => showMpvOsd(message), + showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), logInfo: (message) => logger.info(message), }, @@ -4266,7 +4396,7 @@ const { rememberAttemptedUpdateKey: (key) => { rememberAnilistAttemptedUpdate(key); }, - showMpvOsd: (message) => showMpvOsd(message), + showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS, @@ -5017,7 +5147,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, - tokenizeSubtitle, + tokenizeSubtitle: tokenizeSubtitleRuntime, createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, @@ -5332,13 +5462,13 @@ const { ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(), ensureFrequencyDictionaryLookup: () => frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(), - showMpvOsd: (message: string) => showMpvOsd(message), + showMpvOsd: (message: string) => showConfiguredStatusNotification(message), showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message), showLoadedOsd: (message: string) => startupOsdSequencer.markAnnotationLoadingComplete(message), shouldShowOsdNotification: () => { - const type = getResolvedConfig().ankiConnect.behavior.notificationType; - return type === 'osd' || type === 'both'; + const type = getConfiguredStatusNotificationType(); + return type === 'osd' || type === 'osd-system'; }, }, }, @@ -5391,6 +5521,14 @@ const { }, }, }); + +async function tokenizeSubtitle(text: string): Promise { + if (!isTokenizationWarmupReady()) { + startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...'); + } + return await tokenizeSubtitleRuntime(text); +} + signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({ isTokenizationWarmupReady: () => isTokenizationWarmupReady(), startTokenizationWarmups: async () => { @@ -5891,8 +6029,7 @@ function openYomitanSettings(): boolean { logger.warn( 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', ); - showDesktopNotification('SubMiner', { body: message }); - showMpvOsd(message); + showConfiguredStatusNotification(message, { variant: 'warning' }); return false; } openYomitanSettingsHandler(); @@ -5979,7 +6116,7 @@ const { }, numericShortcutRuntimeMainDeps: { globalShortcut, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), clearTimer: (timer) => clearTimeout(timer), }, @@ -6214,6 +6351,7 @@ function getUpdateService() { { notificationType: getResolvedConfig().updates.notificationType, version }, { showSystemNotification: (title, body) => showDesktopNotification(title, { body }), + showOverlayNotification, showOsdNotification: (message) => { showMpvOsd(message); }, @@ -6238,7 +6376,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ broadcastToOverlayWindows: (channel, mode) => { broadcastToOverlayWindows(channel, mode); }, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), }, cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps), }); @@ -6275,7 +6413,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler = createBuildUpdateLastCardFromClipboardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), updateLastCardFromClipboardCore, }); const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); @@ -6294,7 +6432,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler( const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), triggerFieldGroupingCore, }); const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); @@ -6303,7 +6441,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie const buildMarkLastCardAsAudioCardMainDepsHandler = createBuildMarkLastCardAsAudioCardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), markLastCardAsAudioCardCore, }); const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler(); @@ -6314,7 +6452,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler( const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, getMpvClient: () => appState.mpvClient, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), mineSentenceCardCore, recordCardsMined: (count, noteIds) => { ensureImmersionTrackerStarted(); @@ -6328,7 +6466,7 @@ const mineSentenceCardHandler = createMineSentenceCardHandler( const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({ getSubtitleTimingTracker: () => appState.subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), handleMultiCopyDigitCore, }); const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler(); @@ -6337,7 +6475,7 @@ const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMult const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({ getSubtitleTimingTracker: () => appState.subtitleTimingTracker, writeClipboardText: (text) => clipboard.writeText(text), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), copyCurrentSubtitleCore, }); const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler(); @@ -6348,7 +6486,7 @@ const buildHandleMineSentenceDigitMainDepsHandler = getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getAnkiIntegration: () => appState.ankiIntegration, getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), logError: (message, err) => { logger.error(message, err); }, @@ -6391,7 +6529,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler = appendClipboardVideoToQueueRuntime, getMpvClient: () => appState.mpvClient, readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), sendMpvCommand: (command) => { sendMpvCommandRuntime(appState.mpvClient, command); }, @@ -6530,7 +6668,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen logger.warn('Failed to save Jellyfin subtitle delay.'); } }, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), }); async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { @@ -6587,12 +6725,12 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro } return applyRuntimeOptionResultRuntime( appState.runtimeOptionsManager.cycleOption(id, direction), - (text) => showMpvOsd(text), + (text) => showConfiguredPlaybackFeedback(text), ); }, playNextPlaylistItem: () => sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), }); } @@ -6614,10 +6752,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } return applyRuntimeOptionResultRuntime( 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), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), shiftSubDelayToAdjacentSubtitle: (direction) => @@ -6633,7 +6772,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ registration: { runtimeOptions: { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), }, mainDeps: { getMainWindow: () => overlayManager.getMainWindow(), @@ -6970,6 +7109,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, + showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), getFieldGroupingResolver: () => getFieldGroupingResolver(), @@ -7006,7 +7146,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ openExternal: (url: string) => shell.openExternal(url), logBrowserOpenError: (url: string, error: unknown) => logger.error(`Failed to open browser for texthooker URL: ${url}`, error), - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredStatusNotification(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), @@ -7232,6 +7372,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), onWindowContentReady: () => { + dismissOverlayNotification('overlay-loading-status'); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); if (appState.currentSubText.trim()) { subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); @@ -7274,7 +7415,8 @@ function getJellyfinTrayDiscoveryDeps() { startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options), refreshTrayMenu: () => refreshTrayMenuIfPresent(), logger, - showMpvOsd: (message: string) => showMpvOsd(message), + showMpvOsd: (message: string) => + showConfiguredStatusNotification(message, { title: 'Jellyfin' }), }; } @@ -7421,6 +7563,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, + showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), shouldStartAnkiIntegration: () => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 13abbfa1..c33e0b9a 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -1,4 +1,5 @@ import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types'; +import type { OverlayNotificationPayload } from '../types/notification'; import { SubsyncResolvedConfig } from '../subsync/utils'; import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner'; import type { IpcDepsRuntimeOptions } from '../core/services/ipc'; @@ -124,6 +125,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; @@ -221,6 +223,7 @@ export interface MpvCommandRuntimeServiceDepsParams { openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; + showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle']; @@ -309,6 +312,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( setAnkiIntegration: params.setAnkiIntegration, getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, showDesktopNotification: params.showDesktopNotification, + showOverlayNotification: params.showOverlayNotification, createFieldGroupingCallback: params.createFieldGroupingCallback, broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, getFieldGroupingResolver: params.getFieldGroupingResolver, @@ -414,6 +418,7 @@ export function createMpvCommandRuntimeServiceDeps( openPlaylistBrowser: params.openPlaylistBrowser, runtimeOptionsCycle: params.runtimeOptionsCycle, showMpvOsd: params.showMpvOsd, + showPlaybackFeedback: params.showPlaybackFeedback, mpvReplaySubtitle: params.mpvReplaySubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index a36a2143..6a41f835 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -17,6 +17,7 @@ export interface MpvCommandFromIpcRuntimeDeps { openPlaylistBrowser: () => void | Promise; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; showMpvOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; replayCurrentSubtitle: () => void; playNextSubtitle: () => void; shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; @@ -41,6 +42,7 @@ export function handleMpvCommandFromIpcRuntime( openPlaylistBrowser: deps.openPlaylistBrowser, runtimeOptionsCycle: deps.cycleRuntimeOption, showMpvOsd: deps.showMpvOsd, + showPlaybackFeedback: deps.showPlaybackFeedback, mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle, shiftSubDelayToAdjacentSubtitle: (direction) => diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.ts b/src/main/runtime/anilist-setup-protocol-main-deps.ts index f32ddd45..daff6eec 100644 --- a/src/main/runtime/anilist-setup-protocol-main-deps.ts +++ b/src/main/runtime/anilist-setup-protocol-main-deps.ts @@ -18,8 +18,10 @@ type RegisterSubminerProtocolClientMainDeps = Parameters< export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { return (): NotifyAnilistSetupMainDeps => ({ + getNotificationType: () => deps.getNotificationType?.(), hasMpvClient: () => deps.hasMpvClient(), showMpvOsd: (message: string) => deps.showMpvOsd(message), + showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload), showDesktopNotification: (title: string, options: { body: string }) => deps.showDesktopNotification(title, options), logInfo: (message: string) => deps.logInfo(message), diff --git a/src/main/runtime/anilist-setup-protocol.test.ts b/src/main/runtime/anilist-setup-protocol.test.ts index dbc35de7..cc32f9e9 100644 --- a/src/main/runtime/anilist-setup-protocol.test.ts +++ b/src/main/runtime/anilist-setup-protocol.test.ts @@ -19,6 +19,24 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => { 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', () => { const consume = createConsumeAnilistSetupTokenFromUrlHandler({ consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'), diff --git a/src/main/runtime/anilist-setup-protocol.ts b/src/main/runtime/anilist-setup-protocol.ts index fe4e5d84..15672667 100644 --- a/src/main/runtime/anilist-setup-protocol.ts +++ b/src/main/runtime/anilist-setup-protocol.ts @@ -1,3 +1,5 @@ +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; + export type ConsumeAnilistSetupTokenDeps = { consumeAnilistSetupCallbackUrl: (input: { rawUrl: string; @@ -30,12 +32,35 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis } export function createNotifyAnilistSetupHandler(deps: { + getNotificationType?: () => NotificationType | undefined; hasMpvClient: () => boolean; showMpvOsd: (message: string) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showDesktopNotification: (title: string, options: { body: string }) => void; logInfo: (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()) { deps.showMpvOsd(message); return; diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 3e3708e1..4475b5f0 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -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[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { @@ -80,14 +80,10 @@ test('auto sync notifications never send desktop notifications', () => { }, showDesktopNotification: (title, options) => calls.push(`desktop:${title}:${options.body ?? ''}`), - }); - notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { - getNotificationType: () => 'both', - showOsd: (message) => { - calls.push(`osd:${message}`); - }, - showDesktopNotification: (title, options) => - calls.push(`desktop:${title}:${options.body ?? ''}`), + showOverlayNotification: (payload) => + calls.push( + `overlay:${payload.id}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, + ), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { getNotificationType: () => 'both', @@ -96,9 +92,25 @@ test('auto sync notifications never send desktop notifications', () => { }, showDesktopNotification: (title, options) => 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) => { calls.push(`osd:${message}`); }, @@ -106,14 +118,30 @@ test('auto sync notifications never send desktop notifications', () => { 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[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), { - getNotificationType: () => 'both', + getNotificationType: () => 'osd-system', showOsd: (message) => { calls.push(`osd:${message}`); 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 ?? ''}`), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { - getNotificationType: () => 'both', + getNotificationType: () => 'osd-system', showOsd: (message) => { calls.push(`osd:${message}`); 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 ?? ''}`), }); - 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[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { - getNotificationType: () => 'both', + getNotificationType: () => 'osd-system', showOsd: (message) => { calls.push(`osd:${message}`); }, diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 3610c951..03a306b6 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -1,11 +1,13 @@ import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync'; import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer'; +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent; export interface CharacterDictionaryAutoSyncNotificationDeps { - getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined; + getNotificationType: () => NotificationType | undefined; showOsd: (message: string) => boolean | void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showDesktopNotification: (title: string, options: { body?: string }) => void; startupOsdSequencer?: { notifyCharacterDictionaryStatus: ( @@ -14,39 +16,63 @@ export interface CharacterDictionaryAutoSyncNotificationDeps { }; } -function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean { - return type !== 'none'; +function shouldShowOsd(type: NotificationType): boolean { + return type === 'osd' || type === 'osd-system'; } -function shouldFallbackToDesktop( - type: 'osd' | 'system' | 'both' | 'none' | undefined, +function shouldShowOverlay(type: NotificationType): boolean { + 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'], -): boolean { - return ( - (type === 'system' || type === 'both') && - (phase === 'generating' || phase === 'building' || phase === 'importing') - ); +): OverlayNotificationPayload['variant'] { + if (phase === 'ready') return 'success'; + if (phase === 'failed') return 'error'; + return 'progress'; } export function notifyCharacterDictionaryAutoSyncStatus( event: CharacterDictionaryAutoSyncNotificationEvent, deps: CharacterDictionaryAutoSyncNotificationDeps, ): void { - const type = deps.getNotificationType(); - if (shouldShowOsd(type)) { - if (deps.startupOsdSequencer) { - const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ - phase: event.phase, - message: event.message, + const type = deps.getNotificationType() ?? 'overlay'; + if (type === 'none') return; + + if (shouldShowOverlay(type)) { + if (deps.showOverlayNotification) { + 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)) { - deps.showDesktopNotification('SubMiner', { body: event.message }); - } - return; - } - const shown = deps.showOsd(event.message) !== false; - if (!shown && shouldFallbackToDesktop(type, event.phase)) { + } else if (!shouldShowDesktop(type)) { 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 }); + } } diff --git a/src/main/runtime/character-dictionary-manager-gate.test.ts b/src/main/runtime/character-dictionary-manager-gate.test.ts index 42aa099c..9e317322 100644 --- a/src/main/runtime/character-dictionary-manager-gate.test.ts +++ b/src/main/runtime/character-dictionary-manager-gate.test.ts @@ -18,6 +18,8 @@ function makeDeps(options: { getNotificationType: () => options.notificationType ?? 'osd', openManager: () => calls.push('open'), 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 }) => calls.push(`system:${title}:${opts.body}`), 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}`]], [ 'both', + [ + `overlay:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, + `system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, + ], + ], + [ + 'osd-system', [ `osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, `system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, diff --git a/src/main/runtime/character-dictionary-manager-gate.ts b/src/main/runtime/character-dictionary-manager-gate.ts index 361f8970..7f1c2229 100644 --- a/src/main/runtime/character-dictionary-manager-gate.ts +++ b/src/main/runtime/character-dictionary-manager-gate.ts @@ -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 = 'Enable Name Match in Settings to use the character dictionary manager.'; @@ -8,16 +10,27 @@ export interface CharacterDictionaryManagerGateDeps { getNotificationType: () => CharacterDictionaryManagerNotificationType; openManager: () => void; showOsd: (message: string) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showDesktopNotification: (title: string, options: { body: string }) => void; logWarn?: (message: string, error?: unknown) => void; } function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void { 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); } - if (type === 'system' || type === 'both') { + if (type === 'system' || type === 'both' || type === 'osd-system') { try { deps.showDesktopNotification('SubMiner', { body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE, diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index fb7761f8..a1cc6247 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -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']); }); +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', () => { assert.equal( buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']), diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index e9a81ea2..3b4993c6 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -5,6 +5,7 @@ import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config'; import type { AnkiConnectConfig } from '../../types/anki'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; type ConfigHotReloadAppliedDeps = { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; @@ -26,7 +27,9 @@ type ConfigHotReloadAppliedDeps = { }; type ConfigHotReloadMessageDeps = { + getNotificationType?: () => NotificationType | undefined; showMpvOsd: (message: string) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showDesktopNotification: (title: string, options: { body: string }) => void; }; @@ -183,8 +186,23 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) { return (message: string): void => { - deps.showMpvOsd(message); - deps.showDesktopNotification('SubMiner', { body: message }); + const type = deps.getNotificationType?.() ?? 'osd-system'; + if (type === 'none') { + return; + } + if (type === 'overlay' || type === 'both') { + deps.showOverlayNotification?.({ + title: 'SubMiner', + body: message, + variant: 'warning', + }); + } + if (type === 'osd' || type === 'osd-system') { + deps.showMpvOsd(message); + } + if (type === 'system' || type === 'both' || type === 'osd-system') { + deps.showDesktopNotification('SubMiner', { body: message }); + } }; } diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index 9a4212f8..0e504408 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -55,7 +55,9 @@ export function createBuildConfigHotReloadMessageMainDepsHandler( deps: ConfigHotReloadMessageMainDeps, ) { return (): ConfigHotReloadMessageMainDeps => ({ + getNotificationType: () => deps.getNotificationType?.(), showMpvOsd: (message: string) => deps.showMpvOsd(message), + showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload), showDesktopNotification: (title: string, options: { body: string }) => deps.showDesktopNotification(title, options), }); diff --git a/src/main/runtime/configured-status-notification.test.ts b/src/main/runtime/configured-status-notification.test.ts new file mode 100644 index 00000000..6441a04e --- /dev/null +++ b/src/main/runtime/configured-status-notification.test.ts @@ -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.']); +}); diff --git a/src/main/runtime/configured-status-notification.ts b/src/main/runtime/configured-status-notification.ts new file mode 100644 index 00000000..58c86d38 --- /dev/null +++ b/src/main/runtime/configured-status-notification.ts @@ -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 }); + } +} diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index 6c81a74f..a36ee266 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -16,6 +16,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { }, cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), showMpvOsd: (text) => calls.push(`osd:${text}`), + showPlaybackFeedback: (text) => calls.push(`feedback:${text}`), replayCurrentSubtitle: () => calls.push('replay'), playNextSubtitle: () => calls.push('next'), shiftSubDelayToAdjacentSubtitle: async (direction) => { @@ -34,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { void deps.openPlaylistBrowser(); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); deps.showMpvOsd('hello'); + deps.showPlaybackFeedback?.('primary'); deps.replayCurrentSubtitle(); deps.playNextSubtitle(); void deps.shiftSubDelayToAdjacentSubtitle('next'); @@ -48,6 +50,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { 'youtube-picker', 'playlist-browser', 'osd:hello', + 'feedback:primary', 'replay', 'next', 'shift:next', diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index c236e318..da567f99 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -3,20 +3,27 @@ import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command'; export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( deps: MpvCommandFromIpcRuntimeDeps, ) { - return (): MpvCommandFromIpcRuntimeDeps => ({ - triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), - openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), - openJimaku: () => deps.openJimaku(), - openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), - openPlaylistBrowser: () => deps.openPlaylistBrowser(), - cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), - showMpvOsd: (text: string) => deps.showMpvOsd(text), - replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), - playNextSubtitle: () => deps.playNextSubtitle(), - shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction), - sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), - getMpvClient: () => deps.getMpvClient(), - isMpvConnected: () => deps.isMpvConnected(), - hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(), - }); + return (): MpvCommandFromIpcRuntimeDeps => { + const showPlaybackFeedback = deps.showPlaybackFeedback; + return { + triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), + openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), + openJimaku: () => deps.openJimaku(), + openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), + openPlaylistBrowser: () => deps.openPlaylistBrowser(), + cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), + showMpvOsd: (text: string) => deps.showMpvOsd(text), + ...(showPlaybackFeedback + ? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) } + : {}), + replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), + playNextSubtitle: () => deps.playNextSubtitle(), + shiftSubDelayToAdjacentSubtitle: (direction) => + deps.shiftSubDelayToAdjacentSubtitle(direction), + sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), + getMpvClient: () => deps.getMpvClient(), + isMpvConnected: () => deps.isMpvConnected(), + hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(), + }; + }; } diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index 8f1fc263..f17e6a8b 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -78,6 +78,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, + osdMessages: false, texthookerEnabled: false, }), getDefaultMpvLogPath: () => '/tmp/mp.log', diff --git a/src/main/runtime/overlay-notification-position.test.ts b/src/main/runtime/overlay-notification-position.test.ts new file mode 100644 index 00000000..c41cb58c --- /dev/null +++ b/src/main/runtime/overlay-notification-position.test.ts @@ -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' }, + ); +}); diff --git a/src/main/runtime/overlay-notification-position.ts b/src/main/runtime/overlay-notification-position.ts new file mode 100644 index 00000000..6151ba3f --- /dev/null +++ b/src/main/runtime/overlay-notification-position.ts @@ -0,0 +1,12 @@ +import type { ResolvedConfig } from '../../types/config'; +import type { OverlayNotificationPayload } from '../../types/notification'; + +export function withConfiguredOverlayNotificationPosition( + payload: OverlayNotificationPayload, + config: Pick, +): OverlayNotificationPayload { + return { + ...payload, + position: payload.position ?? config.notifications.overlayPosition, + }; +} diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index 122d0044..32ce7740 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -1,4 +1,5 @@ import type { AnkiConnectConfig } from '../../types'; +import type { OverlayNotificationPayload } from '../../types/notification'; import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options'; type OverlayRuntimeOptionsMainDeps = Parameters< @@ -37,6 +38,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker']; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; @@ -72,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { deps.appState.ankiIntegration = integration; }, showDesktopNotification: deps.showDesktopNotification, + showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 4ba31141..58abb4a1 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -5,6 +5,7 @@ import type { } from '../../types/anki'; import type { BrowserWindow } from 'electron'; import type { WindowGeometry } from '../../types/runtime'; +import type { OverlayNotificationPayload } from '../../types/notification'; import type { BaseWindowTracker } from '../../window-trackers'; type OverlayRuntimeOptions = { @@ -31,6 +32,7 @@ type OverlayRuntimeOptions = { } | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -64,6 +66,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { } | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -91,6 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { getRuntimeOptionsManager: deps.getRuntimeOptionsManager, setAnkiIntegration: deps.setAnkiIntegration, showDesktopNotification: deps.showDesktopNotification, + showOverlayNotification: deps.showOverlayNotification, createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, diff --git a/src/main/runtime/startup-osd-sequencer.test.ts b/src/main/runtime/startup-osd-sequencer.test.ts index 48d6da85..02cb8ab5 100644 --- a/src/main/runtime/startup-osd-sequencer.test.ts +++ b/src/main/runtime/startup-osd-sequencer.test.ts @@ -222,3 +222,35 @@ test('startup OSD keeps dictionary progress pending when mpv osd is unavailable' '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', + ]); +}); diff --git a/src/main/runtime/startup-osd-sequencer.ts b/src/main/runtime/startup-osd-sequencer.ts index 8b7cc9f3..bca9a263 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -1,10 +1,41 @@ +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; + export interface StartupOsdSequencerCharacterDictionaryEvent { phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed'; 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; + showTokenizationLoading: (message: string) => void; markTokenizationReady: () => void; showAnnotationLoading: (message: string) => void; markAnnotationLoadingComplete: (message: string) => void; @@ -12,6 +43,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => } { let tokenizationReady = false; let tokenizationWarmupCompleted = false; + let tokenizationLoadingShown = false; let annotationLoadingMessage: string | null = null; let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null; let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null; @@ -20,7 +52,66 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => const canShowDictionaryStatus = (): boolean => 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 => { if (!canShowDictionaryStatus()) { @@ -55,17 +146,29 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => return { reset: () => { tokenizationReady = tokenizationWarmupCompleted; + tokenizationLoadingShown = false; annotationLoadingMessage = null; pendingDictionaryProgress = null; pendingDictionaryFailure = null; pendingDictionaryReady = null; dictionaryProgressShown = false; }, + showTokenizationLoading: (message) => { + if (tokenizationReady) { + return; + } + tokenizationLoadingShown = true; + notifyTokenization(message, 'progress', true); + }, markTokenizationReady: () => { tokenizationWarmupCompleted = true; tokenizationReady = true; + if (tokenizationLoadingShown) { + notifyTokenization('Subtitle tokenization ready', 'success', false); + tokenizationLoadingShown = false; + } if (annotationLoadingMessage !== null) { - showOsd(annotationLoadingMessage); + notifyAnnotation(annotationLoadingMessage, 'progress', true); return; } flushBufferedDictionaryStatus(); @@ -73,7 +176,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => showAnnotationLoading: (message) => { annotationLoadingMessage = message; if (tokenizationReady) { - showOsd(message); + notifyAnnotation(message, 'progress', true); } }, markAnnotationLoadingComplete: (message) => { @@ -84,7 +187,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => if (flushBufferedDictionaryStatus()) { return; } - showOsd(message); + notifyAnnotation(message, 'success', false); }, notifyCharacterDictionaryStatus: (event) => { if ( diff --git a/src/main/runtime/update/update-notifications.test.ts b/src/main/runtime/update/update-notifications.test.ts index c7d9659a..379b5b75 100644 --- a/src/main/runtime/update/update-notifications.test.ts +++ b/src/main/runtime/update/update-notifications.test.ts @@ -1,8 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; 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 deps = { showSystemNotification: (title: string, body: string) => { @@ -11,19 +12,27 @@ test('notifyUpdateAvailable routes system and osd notifications from config', as showOsdNotification: async (message: string) => { calls.push(`osd:${message}`); }, + showOverlayNotification: (payload: OverlayNotificationPayload) => { + calls.push(`overlay:${payload.title}:${payload.body ?? ''}`); + }, log: (message: string) => { 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: '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); assert.deepEqual(calls, [ + 'overlay: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', '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 () => { throw new Error('mpv disconnected'); }, + showOverlayNotification: () => { + calls.push('overlay'); + }, log: (message) => { calls.push(message); }, @@ -60,6 +72,9 @@ test('notifyUpdateAvailable logs non-error osd failures with thrown value', asyn showOsdNotification: async () => { throw 'mpv disconnected'; }, + showOverlayNotification: () => { + calls.push('overlay'); + }, log: (message) => { calls.push(message); }, diff --git a/src/main/runtime/update/update-notifications.ts b/src/main/runtime/update/update-notifications.ts index 13b7072c..14d0182b 100644 --- a/src/main/runtime/update/update-notifications.ts +++ b/src/main/runtime/update/update-notifications.ts @@ -1,7 +1,9 @@ import type { UpdateNotificationType } from '../../../types/config'; +import type { OverlayNotificationPayload } from '../../../types/notification'; export interface UpdateNotificationDeps { showSystemNotification: (title: string, body: string) => void; + showOverlayNotification: (payload: OverlayNotificationPayload) => void; showOsdNotification: (message: string) => void | Promise; log: (message: string) => void; } @@ -13,10 +15,14 @@ export async function notifyUpdateAvailable( if (options.notificationType === 'none') return; const message = `SubMiner v${options.version} is available`; - if (options.notificationType === 'system' || options.notificationType === 'both') { - deps.showSystemNotification('SubMiner update available', message); + if (options.notificationType === 'overlay' || options.notificationType === 'both') { + 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 { await deps.showOsdNotification(message); } catch (error) { @@ -24,4 +30,11 @@ export async function notifyUpdateAvailable( deps.log(`Update OSD notification failed: ${reason}`); } } + if ( + options.notificationType === 'system' || + options.notificationType === 'both' || + options.notificationType === 'osd-system' + ) { + deps.showSystemNotification('SubMiner update available', message); + } } diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 814efc38..e1c806fd 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -210,6 +210,7 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => { autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, + osdMessages: false, texthookerEnabled: false, }, ); @@ -238,6 +239,7 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, ); @@ -286,6 +288,7 @@ test('launchWindowsMpv attaches a launched video to a running app and disables p autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: true, }, ); @@ -348,6 +351,7 @@ test('launchWindowsMpv leaves plugin auto-start enabled when no running app cont autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, ); @@ -436,6 +440,7 @@ test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, ); diff --git a/src/preload.ts b/src/preload.ts index 7cf33083..bf5d6808 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -59,6 +59,7 @@ import type { YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, + OverlayNotificationEventPayload, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -206,6 +207,11 @@ const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload const onOverlayPointerRecoveryRequestEvent = createQueuedIpcListener( IPC_CHANNELS.event.overlayPointerRecoveryRequest, ); +const onOverlayNotificationEvent = + createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.overlayNotification, + (payload) => payload as OverlayNotificationEventPayload, + ); const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload( IPC_CHANNELS.event.subtitleVisibility, (payload) => payload === true, @@ -229,6 +235,10 @@ const electronAPI: ElectronAPI = { onSubtitleSetEvent(callback); }, onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent, + onOverlayNotification: onOverlayNotificationEvent, + sendOverlayNotificationAction: (notificationId: string, actionId: string) => { + ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId }); + }, onVisibility: (callback: (visible: boolean) => void) => { onSubtitleVisibilityEvent(callback); diff --git a/src/renderer/index.html b/src/renderer/index.html index 5b990df4..0860b824 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,6 +42,12 @@ role="status" aria-live="polite" > +
diff --git a/src/renderer/overlay-content-measurement.test.ts b/src/renderer/overlay-content-measurement.test.ts index 90369121..72e6902d 100644 --- a/src/renderer/overlay-content-measurement.test.ts +++ b/src/renderer/overlay-content-measurement.test.ts @@ -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; + } + } +}); diff --git a/src/renderer/overlay-content-measurement.ts b/src/renderer/overlay-content-measurement.ts index 4bd1061e..9fb30cb7 100644 --- a/src/renderer/overlay-content-measurement.ts +++ b/src/renderer/overlay-content-measurement.ts @@ -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; } diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 13ad1bf7..8ff19797 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -29,7 +29,10 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { const shouldKeepWindowInteractive = isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state); const shouldStayInteractive = - ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive; + ctx.state.isOverSubtitle || + ctx.state.isOverSubtitleSidebar || + ctx.state.isOverOverlayNotification || + shouldKeepWindowInteractive; const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform ? shouldKeepWindowInteractive : shouldStayInteractive; diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts new file mode 100644 index 00000000..59fcd15e --- /dev/null +++ b/src/renderer/overlay-notifications.test.ts @@ -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']); +}); diff --git a/src/renderer/overlay-notifications.ts b/src/renderer/overlay-notifications.ts new file mode 100644 index 00000000..4d9984ef --- /dev/null +++ b/src/renderer/overlay-notifications.ts @@ -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 +> & + Omit & { + 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(); + 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, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 090f1c47..9b12f8d4 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -45,6 +45,10 @@ import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js' import { createPositioningController } from './positioning.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js'; +import { + createOverlayNotificationRenderer, + handleOverlayNotificationEvent, +} from './overlay-notifications.js'; import { createRendererState } from './state.js'; import { createSubtitleRenderer } from './subtitle-render.js'; import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; @@ -112,6 +116,9 @@ function syncSettingsModalSubtitleSuppression(): void { const subtitleRenderer = createSubtitleRenderer(ctx); const measurementReporter = createOverlayContentMeasurementReporter(ctx); +const overlayNotifications = createOverlayNotificationRenderer(ctx, { + onChanged: () => measurementReporter.schedule(), +}); const positioning = createPositioningController(ctx); const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { modalStateReader: { isAnyModalOpen }, @@ -612,6 +619,11 @@ async function init(): Promise { mouseHandlers.restorePointerInteractionState(); }); }); + window.electronAPI.onOverlayNotification((payload) => { + runGuarded('overlay:notification', () => { + handleOverlayNotificationEvent(overlayNotifications, payload); + }); + }); await keyboardHandlers.setupMpvInputForwarding(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index dc639b55..749d7063 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -31,6 +31,7 @@ export type ChordAction = export type RendererState = { isOverSubtitle: boolean; isOverSubtitleSidebar: boolean; + isOverOverlayNotification: boolean; isDragging: boolean; dragStartY: number; startYPercent: number; @@ -143,6 +144,7 @@ export function createRendererState(): RendererState { return { isOverSubtitle: false, isOverSubtitleSidebar: false, + isOverOverlayNotification: false, isDragging: false, dragStartY: 0, startYPercent: 0, diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e7f402d..cdda0f78 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -146,6 +146,226 @@ body:focus-visible, 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 { position: absolute; inset: 0; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index bbddb2ac..5ea6e8e0 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -2,6 +2,7 @@ export type RendererDom = { subtitleRoot: HTMLElement; subtitleContainer: HTMLElement; overlay: HTMLElement; + overlayNotificationStack: HTMLDivElement; controllerStatusToast: HTMLDivElement; overlayErrorToast: HTMLDivElement; secondarySubContainer: HTMLElement; @@ -132,6 +133,7 @@ export function resolveRendererDom(): RendererDom { subtitleRoot: getRequiredElement('subtitleRoot'), subtitleContainer: getRequiredElement('subtitleContainer'), overlay: getRequiredElement('overlay'), + overlayNotificationStack: getRequiredElement('overlayNotificationStack'), controllerStatusToast: getRequiredElement('controllerStatusToast'), overlayErrorToast: getRequiredElement('overlayErrorToast'), secondarySubContainer: getRequiredElement('secondarySubContainer'), diff --git a/src/settings/settings-controls.test.ts b/src/settings/settings-controls.test.ts new file mode 100644 index 00000000..2193ba15 --- /dev/null +++ b/src/settings/settings-controls.test.ts @@ -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 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(); + } +}); diff --git a/src/settings/settings-controls.ts b/src/settings/settings-controls.ts index 16706b09..bc34fd37 100644 --- a/src/settings/settings-controls.ts +++ b/src/settings/settings-controls.ts @@ -216,7 +216,15 @@ export function renderControl( if (field.control === 'select') { 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; option.value = enumValue; option.textContent = enumValue; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 449eb240..bb82ffc7 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -41,6 +41,7 @@ export const IPC_CHANNELS = { reportOverlayContentBounds: 'overlay-content-bounds:report', reportOverlayInteractive: 'overlay-interactive:report', overlayModalOpened: 'overlay:modal-opened', + overlayNotificationAction: 'overlay:notification-action', toggleStatsOverlay: 'stats:toggle-overlay', markActiveVideoWatched: 'immersion:mark-active-video-watched', dispatchSessionAction: 'session-action:dispatch', @@ -144,6 +145,7 @@ export const IPC_CHANNELS = { subtitleSidebarToggle: 'subtitle-sidebar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', configHotReload: 'config:hot-reload', + overlayNotification: 'overlay:notification', }, } as const; diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index 3aacdf18..62c56c99 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -10,6 +10,7 @@ export interface SubminerPluginRuntimeScriptOptConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + osdMessages: boolean; texthookerEnabled: boolean; } @@ -41,6 +42,7 @@ export function buildSubminerPluginRuntimeScriptOptParts( `subminer-auto_start_pause_until_ready=${boolScriptOpt( runtimeConfig.autoStartPauseUntilReady, )}`, + `subminer-osd_messages=${boolScriptOpt(runtimeConfig.osdMessages)}`, `subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`, ]; } diff --git a/src/types.ts b/src/types.ts index c529c97e..53f48a6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export * from './types/anki'; export * from './types/config'; export * from './types/integrations'; +export * from './types/notification'; export * from './types/runtime'; export * from './types/runtime-options'; export * from './types/session-bindings'; diff --git a/src/types/anki.ts b/src/types/anki.ts index e83e374d..c8f35b26 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -1,4 +1,5 @@ import type { AiFeatureConfig } from './integrations'; +import type { NotificationType } from './notification'; import type { NPlusOneMatchMode } from './subtitle'; export interface NotificationOptions { @@ -94,7 +95,7 @@ export interface AnkiConnectConfig { overwriteImage?: boolean; mediaInsertMode?: 'append' | 'prepend'; highlightWord?: boolean; - notificationType?: 'osd' | 'system' | 'both' | 'none'; + notificationType?: NotificationType; autoUpdateNewCards?: boolean; }; metadata?: { diff --git a/src/types/config.ts b/src/types/config.ts index c6e36202..eeae06ea 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -36,6 +36,7 @@ import type { SubtitleSidebarConfig, SubtitleStyleConfig, } from './subtitle'; +import type { NotificationType, OverlayNotificationPosition } from './notification'; export interface WebSocketConfig { enabled?: boolean | 'auto'; @@ -83,7 +84,7 @@ export interface StartupWarmupsConfig { jellyfinRemoteSession?: boolean; } -export type UpdateNotificationType = 'system' | 'osd' | 'both' | 'none'; +export type UpdateNotificationType = NotificationType; export type UpdateChannel = 'stable' | 'prerelease'; export interface UpdatesConfig { @@ -93,6 +94,10 @@ export interface UpdatesConfig { channel?: UpdateChannel; } +export interface NotificationsConfig { + overlayPosition?: OverlayNotificationPosition; +} + export type LogRotation = number; export interface LogFilesConfig { @@ -149,6 +154,7 @@ export interface Config { immersionTracking?: ImmersionTrackingConfig; stats?: StatsConfig; updates?: UpdatesConfig; + notifications?: NotificationsConfig; logging?: { level?: 'debug' | 'info' | 'warn' | 'error'; rotation?: LogRotation; @@ -247,7 +253,7 @@ export interface ResolvedConfig { overwriteImage: boolean; mediaInsertMode: 'append' | 'prepend'; highlightWord: boolean; - notificationType: 'osd' | 'system' | 'both' | 'none'; + notificationType: NotificationType; autoUpdateNewCards: boolean; }; metadata: { @@ -379,6 +385,7 @@ export interface ResolvedConfig { autoOpenBrowser: boolean; }; updates: Required; + notifications: Required; logging: { level: 'debug' | 'info' | 'warn' | 'error'; rotation: LogRotation; diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 00000000..16975573 --- /dev/null +++ b/src/types/notification.ts @@ -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) + ); +} diff --git a/src/types/runtime.ts b/src/types/runtime.ts index bd0e82a5..71b0350a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -41,6 +41,7 @@ import type { RuntimeOptionState, RuntimeOptionValue, } from './runtime-options'; +import type { OverlayNotificationEventPayload } from './notification'; export interface WindowGeometry { x: number; @@ -405,6 +406,8 @@ export interface ElectronAPI { getOverlayLayer: () => 'visible' | 'modal' | null; onSubtitle: (callback: (data: SubtitleData) => void) => void; onOverlayPointerRecoveryRequested: (callback: () => void) => void; + onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void; + sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; getOverlayVisibility: () => Promise;