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..bbf2c55f --- /dev/null +++ b/changes/overlay-notifications.md @@ -0,0 +1,26 @@ +type: changed +area: notifications +breaking: true + +- Added overlay notifications with a Catppuccin Macchiato stack, a 3-second transient timeout, and persistent long-running job notifications for character dictionary sync. +- Added `notifications.overlayPosition` to place overlay notifications at the top left, top center, or top right; top right remains the default. +- Added a notification history panel (default `Ctrl/Cmd+N`, configurable via `shortcuts.toggleNotificationHistory`) that logs every notification shown during the session; the toggle works whether the overlay or mpv has focus, the panel slides in from the same edge as notifications (right when centered), and entries can be removed individually or cleared. +- Made the overlay error/recovery toast follow the configured `notifications.overlayPosition` instead of always pinning to the top-right corner, and kept the notification stack and history panel side synced from that position before first open so left-side history panels slide in from the left. +- Routed startup tokenization, subtitle annotation, and character dictionary status through queued overlay notifications for `overlay`/`both` instead of falling back to mpv OSD while the overlay loads; queued loading cards are shown before their ready update when both happen before the overlay is ready, and the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`. +- Preserved character dictionary checking/building/importing/ready phases in overlay notification history and sent those phases to system notifications when `notificationType` is `both`. +- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness. +- Kept playback feedback such as subtitle visibility, subtitle track, subtitle delay, and AniSkip prompt/skip text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates. +- Routed mpv-plugin restart feedback through the configured overlay/OSD feedback surface so `overlay` and `both` notification modes show restart progress and completion in the overlay, while keeping the loading OSD spinner visible until the overlay reports ready. +- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback. +- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick. +- Stabilized overlay startup notifications so queued progress updates do not replay the card entrance animation or trigger macOS pass-through hover flicker after the loading OSD hands off to overlay notifications. +- Fixed mined-card overlay notifications so `overlay` and `both` modes show generated card thumbnails in both live cards and the notification history panel. +- Added Open in Anki buttons to mined-card overlay notifications and their history entries, with a direct AnkiConnect fallback when the live integration is unavailable. +- Fixed those Open in Anki buttons so their fallback honors runtime AnkiConnect URL overrides and the default AnkiConnect endpoint. +- Added an Update button to overlay update-available notifications so users can start the app update flow from the notification. +- Changed the new-user default for update notifications to `both`, so update alerts appear in both the overlay and system notifications unless configured otherwise. +- Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast. +- Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays. +- Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video. +- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`. +- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected. diff --git a/changes/startup-autoplay-ready.md b/changes/startup-autoplay-ready.md new file mode 100644 index 00000000..577beeca --- /dev/null +++ b/changes/startup-autoplay-ready.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed startup pause-until-ready so SubMiner releases playback after tokenization and overlay content are ready even when playback starts before the first subtitle line. diff --git a/changes/subtitle-overlay-hover-ready.md b/changes/subtitle-overlay-hover-ready.md new file mode 100644 index 00000000..3467c29f --- /dev/null +++ b/changes/subtitle-overlay-hover-ready.md @@ -0,0 +1,9 @@ +type: fixed +area: overlay + +- Fixed visible overlay startup/resume so subtitle bars can be hovered and clicked as soon as the first subtitle line appears, without waiting for the next subtitle update. +- Released playback after the first overlay measurement instead of waiting for cold subtitle annotation warmup, so overlay notifications and subtitle controls do not freeze during visible-overlay startup. +- Primed Linux overlay input from the first measured subtitle/notification surface before playback resumes, so first-line subtitles and startup notifications are clickable immediately. +- Restored visible-overlay loading feedback as an mpv OSD spinner that stops once the overlay is content-ready and visible. +- Starts that OSD spinner when mpv connects, opens media, or the visible overlay is requested, so cold startup shows feedback before the overlay is almost ready. +- Shows an immediate plugin-side mpv OSD on `start-file` for visible overlay startup, even when normal plugin status OSD messages are disabled or the launcher owns the overlay start, and keeps it spinning until Electron reports the visible overlay is content-ready. diff --git a/config.example.jsonc b/config.example.jsonc index 52ba0736..ad0a1c4a 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": "both", // 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. @@ -199,7 +208,8 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. + "toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -539,7 +549,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..18f6bc32 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -216,11 +216,15 @@ 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. + +When media is available, mined-card overlay and system notifications include the same current-frame thumbnail. + `overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged. ## AI Translation @@ -351,7 +355,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/character-dictionary.md b/docs-site/character-dictionary.md index 8dc20574..8b0f7fb5 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -158,6 +158,8 @@ The three collapsible sections can be configured to start open or closed: When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. +These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import. + **Phases:** 1. **checking** - Is there already a cached snapshot for this media ID? diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0f268f91..1ef94580 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 @@ -196,18 +197,46 @@ Configure automatic update checks and update notifications: "updates": { "enabled": true, "checkIntervalHours": 24, - "notificationType": "system", + "notificationType": "both", "channel": "stable" } } ``` -| 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 `"both"`, which means overlay + system. | +| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | + +When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow. + +`osd` and `osd-system` are legacy config-file-only notification values. The Settings window offers `overlay`, `system`, `both`, and `none`; if your config already contains `osd` or `osd-system`, it is shown as the selected value but not offered as a normal choice. If you previously used `both` for mpv OSD + system notifications, set `notificationType` to `"osd-system"` in `config.jsonc` to keep that behavior. + +### Notifications + +Configure where overlay notification cards appear: + +```json +{ + "notifications": { + "overlayPosition": "top-right" + } +} +``` + +| Option | Values | Description | +| ----------------- | ---------------------------------------- | ------------------------------------------------------------------ | +| `overlayPosition` | `"top-left"` \| `"top"` \| `"top-right"` | Position for in-overlay notification cards. Default `"top-right"`. | + +#### Notification history panel + +Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts. + +Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured. + +The equivalent direct CLI command is `--playback-feedback ` (`playbackFeedback` internally). It sends that one non-empty feedback string through the same route controlled by `ankiConnect.behavior.notificationType`; it does not change the saved config. ### Auto-Start Overlay @@ -223,7 +252,7 @@ Control whether the overlay automatically becomes visible when it connects to mp | -------------------- | --------------- | ----------------------------------------------------- | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) | -When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. +When you launch through the SubMiner app or the `subminer` wrapper, the launcher reads these settings from this config and injects them into the mpv plugin at runtime - there is no separate plugin config file to edit. `auto_start_overlay` controls whether the visible overlay shows on auto-start. Two related keys in the `mpv` block tune startup behavior: `mpv.autoStartSubMiner` starts the overlay automatically when a file loads, and `mpv.pauseUntilOverlayReady` pauses mpv on visible auto-start until SubMiner signals overlay/tokenization readiness. On visible-overlay startup, SubMiner brings up the tray and visible overlay shell before tokenization and annotation warmups finish, then releases playback only after autoplay readiness. On Windows, packaged plugin installs also rewrite the plugin socket path to `\\.\pipe\subminer-socket`. @@ -620,31 +649,33 @@ See `config.example.jsonc` for detailed configuration options. "openControllerDebug": "Alt+Shift+C", "openJimaku": "Ctrl+Shift+J", "toggleSubtitleSidebar": "Backslash", + "toggleNotificationHistory": "CommandOrControl+N", "multiCopyTimeoutMs": 3000 } } ``` -| Option | Values | Description | -| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | -| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | -| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | -| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | -| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | -| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | -| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | -| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | -| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | -| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | -| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | -| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | -| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | -| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | -| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | -| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | -| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | +| Option | Values | Description | +| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | +| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | +| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | +| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | +| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | +| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | +| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | +| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | +| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | +| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | +| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | +| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | +| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | +| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | +| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). | **See `config.example.jsonc`** for the complete list of shortcut configuration options. @@ -943,57 +974,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. @@ -1458,18 +1489,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv } ``` -| Option | Values | Description | -| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | -| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) | -| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | -| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) | -| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) | -| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) | -| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) | -| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) | -| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) | -| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) | +| Option | Values | Description | +| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | +| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) | +| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | +| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) | +| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) | +| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) | +| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) | +| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) | +| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) | +| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) | If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list. diff --git a/docs-site/mpv-plugin.md b/docs-site/mpv-plugin.md index ecb1dd5d..13ecacbd 100644 --- a/docs-site/mpv-plugin.md +++ b/docs-site/mpv-plugin.md @@ -1,6 +1,6 @@ # MPV Plugin -**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs *inside* mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window. +**What this is:** mpv is the video player SubMiner overlays subtitles on. The SubMiner mpv plugin is a small Lua script that runs _inside_ mpv and gives you in-player keybindings to control the SubMiner overlay (start/stop/toggle, skip intro, etc.) without leaving the player window. **Who needs this page:** Most users never touch the plugin directly - SubMiner-managed launches (the app, the `subminer` launcher, or the Windows shortcut) inject the bundled plugin automatically for that session, so there is nothing to install into mpv's global `scripts` directory. Read on if you launch mpv from another tool and want SubMiner's in-player controls, or you want to script mpv against SubMiner. @@ -29,18 +29,18 @@ input-ipc-server=\\.\pipe\subminer-socket Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "chord"): -| Chord | Action | -| ---------------- | -------------------------------------- | -| `y-y` | Open menu | -| `y-s` | Start overlay | -| `y-S` | Stop overlay | -| `y-t` | Toggle visible overlay | -| `y-o` | Open settings window | -| `y-r` | Restart overlay | -| `y-c` | Check status | -| `y-h` | Open session help / keybinding modal | -| `v` | Toggle primary subtitle bar visibility | -| `TAB` (default) | Skip intro (AniSkip) | +| Chord | Action | +| --------------- | -------------------------------------- | +| `y-y` | Open menu | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-o` | Open settings window | +| `y-r` | Restart overlay | +| `y-c` | Check status | +| `y-h` | Open session help / keybinding modal | +| `v` | Toggle primary subtitle bar visibility | +| `TAB` (default) | Skip intro (AniSkip) | The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it. See [AniSkip Integration](/aniskip-integration) for setup and details. @@ -151,7 +151,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow). - **File loaded**: If `auto_start=yes`, the plugin starts the overlay. -- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback). +- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback). - **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts). - **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 52ba0736..ad0a1c4a 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": "both", // 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. @@ -199,7 +208,8 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. + "toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -539,7 +549,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/shortcuts.md b/docs-site/shortcuts.md index 0439123c..179520e7 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -82,6 +82,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | 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/docs-site/usage.md b/docs-site/usage.md index aa0f5e41..09a776ef 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -132,6 +132,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle SubMiner.AppImage --start --dev # Enable app/dev mode only SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode +SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface SubMiner.AppImage --yomitan # Open Yomitan settings SubMiner.AppImage --settings # Open SubMiner settings window SubMiner.AppImage --jellyfin # Open Jellyfin setup window @@ -163,6 +164,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta Launcher pass-through commands also support `--password-store=` and forward it to the app when present. Override with e.g. `--password-store=basic_text`. - Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug` (or `SubMiner.exe --start --dev --log-level debug` on Windows). +- `--playback-feedback ` (also `--playback-feedback=`) sends a non-empty text string through the playback-feedback route used for recording/playback prompts. For example: `SubMiner.AppImage --playback-feedback "your feedback"`. ### Windows mpv Shortcut diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index b58d59f3..07b67272 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -64,6 +64,25 @@ prefetch work and re-centers prefetch around the live playback time. - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is written. +## Startup Ready Release + +- `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before + releasing the mpv startup gate. +- Visible-overlay startup creates the tray and visible overlay shell before tokenization and + annotation warmups continue. Cold `--start --background --managed-playback` launches still handle + initial args before the deferred Yomitan wait. +- Overlay-routed startup notifications are queued in the main process until an overlay window has + finished loading. Progress notifications with the same id are upserted so spinner ticks do not + flood a cold-start overlay, while events with distinct history ids are retained for phase-level + history such as character dictionary checking/building/importing. +- The mpv plugin has a 30-second fallback for cold starts; app-side retry/release budgets match that + window so readiness can still arrive before fallback resumes playback. +- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and + waits for a fresh measured subtitle rectangle before signaling readiness. +- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after + tokenization warmup and visible overlay content-ready. This releases playback without waiting for + a later subtitle event that cannot happen while mpv is paused. + ## Linux/X11 Window Shape - `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with diff --git a/docs/plans/2026-06-06-early-managed-overlay-startup-design.md b/docs/plans/2026-06-06-early-managed-overlay-startup-design.md new file mode 100644 index 00000000..ce940042 --- /dev/null +++ b/docs/plans/2026-06-06-early-managed-overlay-startup-design.md @@ -0,0 +1,29 @@ + + +# Early Managed Overlay Startup Design + +Status: approved +Date: 2026-06-06 + +## Problem + +Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay +unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the +overlay surface and fall back to non-overlay status paths. + +## Chosen Approach + +For cold `--start --background --managed-playback` launches, handle initial args before waiting for +the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately +while the existing tokenization warmups continue in the background. + +The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready +signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing +second-instance attach behavior remains unchanged: when the launcher finds an already-running +background app, it sends the same control command to that process and reuses its warmups/tokenizer. + +## Checks + +- Add a startup ordering regression test for managed background playback. +- Keep the existing deferred startup ordering for non-managed launches. +- Run the startup/runtime test slice plus SubMiner verification lane. diff --git a/docs/plans/2026-06-09-macos-notification-hover-design.md b/docs/plans/2026-06-09-macos-notification-hover-design.md new file mode 100644 index 00000000..31303e71 --- /dev/null +++ b/docs/plans/2026-06-09-macos-notification-hover-design.md @@ -0,0 +1,27 @@ + + +# macOS Notification Hover Stability Design + +Status: approved +Date: 2026-06-09 + +## Problem + +On macOS, hovering a character dictionary build notification can make the card flicker and slide as +if it is hiding, then snap back. The likely trigger is the notification stack changing the overlay +window's mouse-passthrough state for a progress card that has no user action. + +## Chosen Approach + +Keep non-action overlay notifications visually stable and click-through on hover. Only notifications +with explicit actions should request interactive overlay input. The notification history panel keeps +its existing interactive behavior. + +This avoids a macOS mouseenter/mouseleave passthrough loop for passive progress cards while +preserving clickable notification actions. + +## Checks + +- Add a renderer regression test for passive notification hover. +- Keep action-bearing notification cards interactive. +- Run the targeted overlay notification and mouse-ignore tests. 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..d484674d 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,10 +338,12 @@ 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; let overlayConfigDir: string | undefined; + let overlayLoadingOsd: boolean | undefined; try { process.env.XDG_CONFIG_HOME = xdgConfigHome; @@ -349,7 +354,19 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), checkDependencies: () => {}, registerCleanup: () => {}, - startMpv: async () => {}, + startMpv: async ( + _target, + _targetKind, + _args, + _socketPath, + _appPath, + _preloadedSubtitles, + options, + ) => { + overlayLoadingOsd = ( + options?.runtimePluginConfig as { overlayLoadingOsd?: boolean } | undefined + )?.overlayLoadingOsd; + }, waitForUnixSocketReady: async () => true, startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { overlayConfigDir = configDir; @@ -366,6 +383,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app assert.equal(availabilityConfigDir, expectedConfigDir); assert.equal(overlayConfigDir, expectedConfigDir); + assert.equal(overlayLoadingOsd, true); } finally { if (originalXdgConfigHome === undefined) { delete process.env.XDG_CONFIG_HOME; @@ -395,6 +413,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/commands/playback-command.ts b/launcher/commands/playback-command.ts index 2b546fa3..93567b4a 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -232,6 +232,14 @@ export async function runPlaybackCommandWithDeps( ? { ...pluginRuntimeConfig, autoStart: false } : pluginRuntimeConfig; + const shouldShowOverlayLoadingOsd = + !isAppOwnedYoutubeFlow && + (pluginRuntimeConfig.autoStartVisibleOverlay || args.startOverlay || args.autoStartOverlay) && + (pluginRuntimeConfig.autoStart || + args.startOverlay || + args.autoStartOverlay || + shouldLauncherAttachRunningApp); + const shouldPauseUntilOverlayReady = pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStartVisibleOverlay && @@ -266,6 +274,7 @@ export async function runPlaybackCommandWithDeps( } : {}), backend: args.backend, + overlayLoadingOsd: shouldShowOverlayLoadingOsd, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled, }, }, diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index b9ed4419..55b0f99b 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', @@ -175,7 +195,10 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin 'subminer-backend=x11', 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', + 'subminer-overlay_loading_osd=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-auto_start_pause_until_ready_timeout_seconds=30', + 'subminer-osd_messages=yes', 'subminer-texthooker_enabled=no', ], ); @@ -191,6 +214,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: true, + osdMessages: false, texthookerEnabled: false, }, '/fallback/SubMiner.AppImage', @@ -201,7 +225,10 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri 'subminer-backend=x11', 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', + 'subminer-overlay_loading_osd=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-auto_start_pause_until_ready_timeout_seconds=30', + '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..3071d88c 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -207,6 +207,8 @@ export interface PluginRuntimeConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + overlayLoadingOsd?: boolean; + osdMessages: boolean; texthookerEnabled: boolean; } diff --git a/package.json b/package.json index c161eed9..ee12fd4a 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", - "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", + "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/shared/mpv-x11-backend.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/overlay-runtime.test.ts src/main/runtime/macos-mpv-focus.test.ts src/main/runtime/macos-modal-focus-handoff.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-ready-gate.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/visible-overlay-autoplay-readiness.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/core/utils/electron-backend.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/linux-overlay-pointer-interaction.test.ts src/main/runtime/linux-overlay-zorder-keepalive.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/overlay-content-measurement.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts src/core/services/overlay-visibility.test.ts src/core/services/overlay-window-config.test.ts src/core/services/overlay-window.test.ts src/main/main-wiring.test.ts src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/overlay-modal-input-state.test.ts src/main/runtime/overlay-window-factory-main-deps.test.ts src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-layout-main-deps.test.ts src/main/runtime/overlay-window-layout.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/yomitan-extension-overlay-reload.test.ts src/renderer/modals/subtitle-sidebar.test.ts src/renderer/overlay-mouse-ignore.test.ts src/main/runtime/linux-visible-overlay-window-mode.test.ts src/main/runtime/linux-x11-cursor-point.test.ts src/renderer/renderer-init-order.test.ts", diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 3632e622..eb967e37 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -104,6 +104,14 @@ function M.create(ctx) return options_helper.coerce_bool(raw_visible_overlay, false) end + local function resolve_overlay_loading_osd_enabled() + local raw_overlay_loading_osd = opts.overlay_loading_osd + if raw_overlay_loading_osd == nil then + raw_overlay_loading_osd = opts["overlay-loading-osd"] + end + return options_helper.coerce_bool(raw_overlay_loading_osd, false) + end + local function next_auto_start_retry_generation() state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1 return state.auto_start_retry_generation @@ -143,6 +151,14 @@ function M.create(ctx) and not (state.overlay_running and state.auto_play_ready_signal_seen == true) end + local function should_show_overlay_loading_osd() + return ( + resolve_overlay_loading_osd_enabled() + or (resolve_auto_start_enabled() and resolve_auto_start_visible_overlay_enabled()) + ) + and not state.suppress_ready_overlay_restore + end + local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) if generation ~= state.auto_start_retry_generation then return @@ -169,6 +185,7 @@ function M.create(ctx) .. process.describe_mpv_ipc_socket_match(opts.socket_path) .. ")" ) + process.stop_overlay_loading_osd() return end @@ -180,6 +197,9 @@ function M.create(ctx) end local function on_start_file() + if should_show_overlay_loading_osd() then + process.start_overlay_loading_osd() + end if state.pending_reload_media_identity ~= nil then local media_identity = resolve_media_identity() if media_identity ~= nil and media_identity ~= state.pending_reload_media_identity then @@ -233,6 +253,7 @@ function M.create(ctx) end if same_media_reload then + process.stop_overlay_loading_osd() subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") if state.app_managed_playback_active then return @@ -260,6 +281,7 @@ function M.create(ctx) end if state.app_managed_playback_active then + process.stop_overlay_loading_osd() subminer_log("debug", "lifecycle", "Skipping plugin auto-start for app-managed subtitle preload") return end @@ -276,6 +298,7 @@ function M.create(ctx) next_auto_start_retry_generation() hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() + process.stop_overlay_loading_osd() clear_pending_visible_overlay_hide() state.auto_play_ready_signal_seen = false state.current_media_identity = nil @@ -295,6 +318,7 @@ function M.create(ctx) hover.clear_hover_overlay() end) mp.register_event("end-file", function(event) + process.stop_overlay_loading_osd() process.disarm_auto_play_ready_gate() hover.clear_hover_overlay() local reason = type(event) == "table" and event.reason or nil diff --git a/plugin/subminer/log.lua b/plugin/subminer/log.lua index a6542acd..b72ff315 100644 --- a/plugin/subminer/log.lua +++ b/plugin/subminer/log.lua @@ -43,8 +43,8 @@ function M.create(ctx) end end - local function show_osd(message) - if opts.osd_messages then + local function show_osd(message, options) + if opts.osd_messages or (options and options.force == true) then local payload = "SubMiner: " .. message local sent = false if type(mp.osd_message) == "function" then diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 6fedae84..ff236566 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 @@ -42,6 +43,9 @@ function M.create(ctx) mp.register_script_message("subminer-autoplay-ready", function() process.notify_auto_play_ready() end) + mp.register_script_message("subminer-overlay-loading-ready", function() + process.stop_overlay_loading_osd() + end) mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json) hover.handle_hover_message(payload_json) end) @@ -49,7 +53,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/plugin/subminer/options.lua b/plugin/subminer/options.lua index 163a5339..c86b84a1 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -31,9 +31,10 @@ function M.load(options_lib, default_socket_path) backend = "auto", auto_start = false, auto_start_visible_overlay = false, + overlay_loading_osd = false, auto_start_pause_until_ready = true, auto_start_pause_until_ready_owns_initial_pause = false, - auto_start_pause_until_ready_timeout_seconds = 15, + auto_start_pause_until_ready_timeout_seconds = 30, osd_messages = true, log_level = "info", } diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 398cd19a..8905d7d2 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -4,9 +4,12 @@ local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 +local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading " +local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" } +local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" -local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 +local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30 local DUPLICATE_VISIBLE_OVERLAY_TOGGLE_SECONDS = 0.25 function M.create(ctx) @@ -53,6 +56,14 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) end + local function resolve_osd_messages_enabled() + local raw_osd_messages = opts.osd_messages + if raw_osd_messages == nil then + raw_osd_messages = opts["osd-messages"] + end + return options_helper.coerce_bool(raw_osd_messages, false) + end + local function resolve_pause_until_ready_owns_initial_pause() local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause if raw_owns_initial_pause == nil then @@ -246,6 +257,42 @@ function M.create(ctx) state.auto_play_ready_osd_timer = nil end + local function clear_overlay_loading_osd_timer() + local timer = state.overlay_loading_osd_timer + if timer and timer.kill then + timer:kill() + end + state.overlay_loading_osd_timer = nil + end + + local function stop_overlay_loading_osd() + state.overlay_loading_osd_active = false + state.overlay_loading_osd_frame = 1 + clear_overlay_loading_osd_timer() + end + + local function start_overlay_loading_osd() + if state.overlay_loading_osd_active then + return + end + state.overlay_loading_osd_active = true + state.overlay_loading_osd_frame = 1 + local function show_next_overlay_loading_frame() + local frame_index = state.overlay_loading_osd_frame or 1 + local frame = OVERLAY_LOADING_OSD_FRAMES[frame_index] or OVERLAY_LOADING_OSD_FRAMES[1] + show_osd(OVERLAY_LOADING_OSD_PREFIX .. frame, { force = true }) + state.overlay_loading_osd_frame = (frame_index % #OVERLAY_LOADING_OSD_FRAMES) + 1 + end + show_next_overlay_loading_frame() + if type(mp.add_periodic_timer) == "function" then + state.overlay_loading_osd_timer = mp.add_periodic_timer(OVERLAY_LOADING_OSD_REFRESH_SECONDS, function() + if state.overlay_loading_osd_active then + show_next_overlay_loading_frame() + end + end) + end + end + local function disarm_auto_play_ready_gate(options) local should_resume = options == nil or options.resume_playback ~= false local was_armed = state.auto_play_ready_gate_armed @@ -264,8 +311,11 @@ function M.create(ctx) return false end local should_resume_playback = state.auto_play_ready_should_resume_playback == true + if resolve_osd_messages_enabled() then + stop_overlay_loading_osd() + show_osd(AUTO_PLAY_READY_READY_OSD) + end disarm_auto_play_ready_gate({ resume_playback = false }) - show_osd(AUTO_PLAY_READY_READY_OSD) if should_resume_playback then mp.set_property_native("pause", false) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) @@ -287,8 +337,11 @@ function M.create(ctx) end state.auto_play_ready_gate_armed = true mp.set_property_native("pause", true) - show_osd(AUTO_PLAY_READY_LOADING_OSD) - if type(mp.add_periodic_timer) == "function" then + if resolve_osd_messages_enabled() then + stop_overlay_loading_osd() + show_osd(AUTO_PLAY_READY_LOADING_OSD) + end + if resolve_osd_messages_enabled() and type(mp.add_periodic_timer) == "function" then state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() if state.auto_play_ready_gate_armed then show_osd(AUTO_PLAY_READY_LOADING_OSD) @@ -375,6 +428,9 @@ function M.create(ctx) table.insert(args, "--texthooker") end end + if action == "playback-feedback" and type(overrides.message) == "string" and overrides.message ~= "" then + table.insert(args, overrides.message) + end return args end @@ -462,6 +518,27 @@ function M.create(ctx) end) end + local function notify_playback_feedback(message, fallback) + if type(message) ~= "string" or message == "" then + return + end + if resolve_osd_messages_enabled() then + show_osd(message) + return + end + if not binary.ensure_binary_available() then + if fallback then + fallback() + end + return + end + run_control_command_async("playback-feedback", { message = message }, function(ok) + if not ok and fallback then + fallback() + end + end) + end + local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt) attempt = attempt or 1 run_control_command_async("app-ping", nil, function(_ok, result) @@ -543,6 +620,7 @@ function M.create(ctx) if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") + stop_overlay_loading_osd() show_osd("Error: binary not found") return end @@ -627,6 +705,7 @@ function M.create(ctx) state.overlay_running = false state.auto_play_ready_signal_seen = false subminer_log("error", "process", "Overlay start failed after retries: " .. reason) + stop_overlay_loading_osd() show_osd("Overlay start failed") release_auto_play_ready_gate("overlay-start-failed") return @@ -679,6 +758,7 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false state.auto_play_ready_signal_seen = false + stop_overlay_loading_osd() disarm_auto_play_ready_gate() show_osd("Stopped") end @@ -690,6 +770,7 @@ function M.create(ctx) return end state.suppress_ready_overlay_restore = true + stop_overlay_loading_osd() run_control_command_async("hide-visible-overlay", nil, function(ok, result) if ok then @@ -794,14 +875,22 @@ function M.create(ctx) return end + local function show_restart_feedback(message) + notify_playback_feedback(message, function() + show_osd(message) + end) + end + + start_overlay_loading_osd() subminer_log("info", "process", "Restarting overlay...") - show_osd("Restarting...") + show_restart_feedback("Restarting...") run_control_command_async("stop", nil, function(ok, result) if not ok then local reason = result and result.stderr or "unknown error" subminer_log("warn", "process", "Restart stop command failed: " .. reason) - show_osd("Restart failed") + stop_overlay_loading_osd() + show_restart_feedback("Restart failed") return end @@ -836,14 +925,25 @@ function M.create(ctx) "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") ) - show_osd("Restart failed") + stop_overlay_loading_osd() + show_restart_feedback("Restart failed") else wait_for_app_ping_state(true, "own the single-instance lock", function() - run_control_command_async("show-visible-overlay") - show_osd("Restarted successfully") + run_control_command_async("show-visible-overlay", nil, function(ok) + if ok then + show_restart_feedback("Restarted successfully") + else + show_restart_feedback("Restart failed") + end + end) end, function() - run_control_command_async("show-visible-overlay") - show_osd("Restarted successfully") + run_control_command_async("show-visible-overlay", nil, function(ok) + if ok then + show_restart_feedback("Restarted successfully") + else + show_restart_feedback("Restart failed") + end + end) end) end end) @@ -852,7 +952,8 @@ function M.create(ctx) ensure_texthooker_running(function() end) end end, function() - show_osd("Restart failed") + stop_overlay_loading_osd() + show_restart_feedback("Restart failed") end) end) end @@ -877,6 +978,7 @@ function M.create(ctx) describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, + notify_playback_feedback = notify_playback_feedback, record_visible_overlay_visibility = record_visible_overlay_visibility, run_binary_command_async = run_binary_command_async, parse_start_script_message_overrides = parse_start_script_message_overrides, @@ -893,6 +995,8 @@ function M.create(ctx) check_binary_available = check_binary_available, notify_auto_play_ready = notify_auto_play_ready, disarm_auto_play_ready_gate = disarm_auto_play_ready_gate, + start_overlay_loading_osd = start_overlay_loading_osd, + stop_overlay_loading_osd = stop_overlay_loading_osd, } end diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 59fe9dc8..4e9d1c2d 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -244,6 +244,8 @@ function M.create(ctx) return { "--toggle-secondary-sub" } elseif action_id == "toggleSubtitleSidebar" then return { "--toggle-subtitle-sidebar" } + elseif action_id == "toggleNotificationHistory" then + return { "--session-action", '{"actionId":"toggleNotificationHistory"}' } elseif action_id == "markAudioCard" then return { "--mark-audio-card" } elseif action_id == "markWatched" then diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 0ae124e2..5174c7bf 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -24,6 +24,9 @@ function M.new() auto_play_ready_osd_timer = nil, auto_play_ready_signal_seen = false, auto_play_ready_initial_pause_ownership_consumed = false, + overlay_loading_osd_active = false, + overlay_loading_osd_timer = nil, + overlay_loading_osd_frame = 1, pending_visible_overlay_hide_timer = nil, pending_visible_overlay_hide_generation = 0, suppress_ready_overlay_restore = false, diff --git a/scripts/test-plugin-restart-feedback.lua b/scripts/test-plugin-restart-feedback.lua new file mode 100644 index 00000000..4113082c --- /dev/null +++ b/scripts/test-plugin-restart-feedback.lua @@ -0,0 +1,230 @@ +package.path = "plugin/subminer/?.lua;" .. package.path + +local process_module = require("process") +local options_helper = require("options") + +local function assert_true(condition, message) + if condition then + return + end + error(message or "assert_true failed") +end + +local function has_arg(args, target) + for _, value in ipairs(args or {}) do + if value == target then + return true + end + end + return false +end + +local function count_feedback(recorded, target) + local count = 0 + for _, message in ipairs(recorded.feedback) do + if message == target then + count = count + 1 + end + end + return count +end + +local function create_restart_runtime(config) + config = config or {} + local recorded = { + async_calls = {}, + feedback = {}, + osd = {}, + periodic_timers = {}, + } + local app_ping_index = 0 + local opts = { + binary_path = "/tmp/SubMiner", + socket_path = "/tmp/subminer-socket", + backend = "x11", + osd_messages = config.osd_messages == true, + texthooker_enabled = false, + log_level = "info", + } + local state = { + binary_path = opts.binary_path, + overlay_running = true, + texthooker_running = false, + } + + local mp = {} + + function mp.command_native_async(command, callback) + recorded.async_calls[#recorded.async_calls + 1] = command + local args = command.args or {} + if has_arg(args, "--playback-feedback") then + recorded.feedback[#recorded.feedback + 1] = args[#args] + callback(true, { status = 0, stdout = "", stderr = "" }, nil) + return + end + if has_arg(args, "--app-ping") then + app_ping_index = app_ping_index + 1 + local statuses = config.app_ping_statuses or { 1, 0 } + local status = statuses[app_ping_index] or statuses[#statuses] + callback(status == 0, { status = status, stdout = "", stderr = "" }, nil) + return + end + if has_arg(args, "--show-visible-overlay") and not has_arg(args, "--start") then + local status = config.show_visible_overlay_status or 0 + callback(status == 0, { status = status, stdout = "", stderr = "" }, nil) + return + end + callback(true, { status = 0, stdout = "", stderr = "" }, nil) + end + + function mp.add_timeout(_, callback) + if config.run_timeouts_immediately and callback then + callback() + end + return { + killed = false, + kill = function(self) + self.killed = true + end, + callback = callback, + } + end + + function mp.add_periodic_timer() + local timer = { + killed = false, + kill = function(self) + self.killed = true + end, + } + recorded.periodic_timers[#recorded.periodic_timers + 1] = timer + return timer + end + + function mp.get_property(name) + if name == "input-ipc-server" then + return opts.socket_path + end + return "" + end + + function mp.get_time() + return 1 + end + + function mp.set_property_native() end + + local process = process_module.create({ + mp = mp, + utils = {}, + opts = opts, + state = state, + binary = { + ensure_binary_available = function() + return true + end, + }, + environment = { + is_linux = function() + return false + end, + detect_backend = function() + return "x11" + end, + resolve_subminer_config_dir = function() + return "/tmp" + end, + join_path = function(...) + return table.concat({ ... }, "/") + end, + }, + options_helper = options_helper, + log = { + normalize_log_level = function(level) + return level or "info" + end, + subminer_log = function() end, + show_osd = function(message, options) + if opts.osd_messages or (options and options.force == true) then + recorded.osd[#recorded.osd + 1] = message + end + end, + }, + }) + + return { + process = process, + recorded = recorded, + } +end + +do + local runtime = create_restart_runtime({ osd_messages = false }) + + runtime.process.restart_overlay() + + assert_true( + runtime.recorded.osd[1] == "Overlay loading |", + "restart should show the forced overlay loading OSD while the overlay reloads" + ) + assert_true( + #runtime.recorded.periodic_timers == 1, + "restart should refresh the forced overlay loading OSD while the overlay reloads" + ) + assert_true( + runtime.recorded.feedback[1] == "Restarting...", + "restart should route progress through playback feedback" + ) + assert_true( + runtime.recorded.feedback[#runtime.recorded.feedback] == "Restarted successfully", + "restart should route success through playback feedback" + ) + assert_true( + runtime.recorded.periodic_timers[1].killed ~= true, + "restart should keep the loading OSD alive until the overlay reports ready" + ) +end + +do + local runtime = create_restart_runtime({ + osd_messages = false, + show_visible_overlay_status = 1, + }) + + runtime.process.restart_overlay() + + assert_true( + count_feedback(runtime.recorded, "Restarted successfully") == 0, + "restart should not show success feedback when show-visible-overlay fails after ready ping" + ) + assert_true( + runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed", + "restart should show failure feedback when show-visible-overlay fails after ready ping" + ) +end + +do + local statuses = { 1 } + for _ = 1, 20 do + statuses[#statuses + 1] = 1 + end + local runtime = create_restart_runtime({ + app_ping_statuses = statuses, + osd_messages = false, + run_timeouts_immediately = true, + show_visible_overlay_status = 1, + }) + + runtime.process.restart_overlay() + + assert_true( + count_feedback(runtime.recorded, "Restarted successfully") == 0, + "restart should not show success feedback when fallback show-visible-overlay fails after ping timeout" + ) + assert_true( + runtime.recorded.feedback[#runtime.recorded.feedback] == "Restart failed", + "restart should show failure feedback when fallback show-visible-overlay fails after ping timeout" + ) +end + +print("plugin restart feedback tests: OK") diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index bf0be497..5967b991 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -900,6 +900,31 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + auto_start_visible_overlay = "yes", + overlay_loading_osd = "yes", + osd_messages = false, + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for explicit early overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "explicit overlay loading OSD option should show spinner even when plugin auto-start is disabled" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1539,6 +1564,91 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + osd_messages = false, + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for early overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "auto-start visible overlay should force overlay loading OSD spinner on start-file" + ) + assert_true( + #recorded.periodic_timers == 1, + "auto-start visible overlay should refresh the early overlay loading OSD" + ) + local overlay_loading_timer = recorded.periodic_timers[1] + recorded.periodic_timers[1].callback() + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading /"), + "auto-start visible overlay should advance the early overlay loading OSD spinner" + ) + fire_event(recorded, "file-loaded") + assert_true( + overlay_loading_timer.killed ~= true, + "autoplay gate should keep forced overlay loading OSD alive while normal plugin OSD messages are disabled" + ) + assert_true( + #recorded.periodic_timers == 1, + "autoplay gate should not replace forced overlay loading OSD with a suppressed tokenization OSD timer" + ) + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + overlay_loading_timer.killed ~= true, + "autoplay readiness should not stop forced overlay loading OSD before overlay content is ready" + ) + overlay_loading_timer.callback() + assert_true( + has_osd_message(recorded.osd, "SubMiner: Overlay loading -"), + "forced overlay loading OSD should keep spinning during the overlay startup gap" + ) + assert_true( + recorded.script_messages["subminer-overlay-loading-ready"] ~= nil, + "overlay loading ready script message should be registered" + ) + recorded.script_messages["subminer-overlay-loading-ready"]() + assert_true( + recorded.periodic_timers[1].killed == true, + "overlay loading ready should stop the early overlay loading OSD refresher" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for hidden overlay loading OSD scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + assert_true( + not has_osd_message(recorded.osd, "SubMiner: Overlay loading |"), + "auto-start hidden visible overlay should not show early overlay loading OSD" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1759,6 +1869,32 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + auto_start_pause_until_ready_owns_initial_pause = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + paused = true, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for default pause timeout scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true( + has_timeout(recorded.timeouts, 30), + "pause-until-ready default timeout should give cold app startup 30 seconds" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index af997abf..c876745d 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => { ); }); +test('AnkiConnectClient opens a note in the Anki browser', async () => { + const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { + client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; + }; + const calls: Array<{ action: string; params: unknown }> = []; + client.client = { + post: async (_url, body) => { + calls.push({ action: body.action, params: body.params }); + return { data: { result: [], error: null } }; + }, + }; + + await ( + client as unknown as { openNoteInBrowser: (noteId: number) => Promise } + ).openNoteInBrowser(12345); + + assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]); +}); + test('AnkiConnectClient derives field names from sampled notes in a deck', async () => { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; diff --git a/src/anki-connect.ts b/src/anki-connect.ts index 79a80f20..3d0cb66e 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -247,6 +247,13 @@ export class AnkiConnectClient { return (result as Record[]) || []; } + async openNoteInBrowser(noteId: number): Promise { + if (!Number.isInteger(noteId) || noteId <= 0) { + throw new Error('Invalid Anki note id'); + } + await this.invoke('guiBrowse', { query: `nid:${noteId}` }); + } + async updateNoteFields(noteId: number, fields: Record): Promise { await this.invoke('updateNoteFields', { note: { diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 03bf4b37..e3b4546d 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -7,6 +7,14 @@ import { AnkiIntegration } from './anki-integration'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { AnkiConnectConfig } from './types'; +type TestOverlayNotificationPayload = { + title: string; + body?: string; + image?: string; + variant?: string; + actions?: Array<{ id: string; label: string; noteId?: number }>; +}; + interface IntegrationTestContext { integration: AnkiIntegration; calls: { @@ -406,6 +414,188 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); +test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => { + const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const generatedFrom: Array<{ videoPath: string; timestamp: number }> = []; + const cleanupPaths: string[] = []; + const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png'); + + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'both', + }, + }, + {} as never, + { + currentVideoPath: '/tmp/show.mkv', + currentTimePos: 123.45, + } as never, + undefined, + (title, options) => { + desktopNotifications.push({ title, body: options.body, icon: options.icon }); + }, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayNotifications.push(payload as TestOverlayNotificationPayload); + }, + ); + + ( + integration as unknown as { + mediaGenerator: { + generateNotificationIcon: (videoPath: string, timestamp: number) => Promise; + writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string; + scheduleNotificationIconCleanup: (filePath: string) => void; + }; + } + ).mediaGenerator = { + generateNotificationIcon: async (videoPath, timestamp) => { + generatedFrom.push({ videoPath, timestamp }); + return Buffer.from('png'); + }, + writeNotificationIconToFile: (iconBuffer, noteId) => { + assert.equal(iconBuffer.toString(), 'png'); + assert.equal(noteId, 42); + return notificationIconPath; + }, + scheduleNotificationIconCleanup: (filePath) => { + cleanupPaths.push(filePath); + }, + }; + + await ( + integration as unknown as { + showNotification: (noteId: number, label: string | number) => Promise; + } + ).showNotification(42, '食べる'); + + assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]); + assert.equal(overlayNotifications.length, 1); + assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated'); + assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる'); + assert.equal( + overlayNotifications[0]?.image, + `data:image/png;base64,${Buffer.from('png').toString('base64')}`, + ); + assert.deepEqual(overlayNotifications[0]?.actions, [ + { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, + ]); + assert.deepEqual(desktopNotifications, [ + { + title: 'Anki Card Updated', + body: 'Updated card: 食べる', + icon: notificationIconPath, + }, + ]); + assert.deepEqual(cleanupPaths, [notificationIconPath]); +}); + +test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { + const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const cleanupPaths: string[] = []; + + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'both', + }, + }, + {} as never, + { + currentVideoPath: '/tmp/show.mkv', + currentTimePos: 123.45, + } as never, + undefined, + (title, options) => { + desktopNotifications.push({ title, body: options.body, icon: options.icon }); + }, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayNotifications.push(payload as TestOverlayNotificationPayload); + }, + ); + + ( + integration as unknown as { + mediaGenerator: { + generateNotificationIcon: () => Promise; + writeNotificationIconToFile: () => string; + scheduleNotificationIconCleanup: (filePath: string) => void; + }; + } + ).mediaGenerator = { + generateNotificationIcon: async () => Buffer.from('png'), + writeNotificationIconToFile: () => { + throw new Error('disk full'); + }, + scheduleNotificationIconCleanup: (filePath) => { + cleanupPaths.push(filePath); + }, + }; + + await ( + integration as unknown as { + showNotification: (noteId: number, label: string | number) => Promise; + } + ).showNotification(42, '食べる'); + + assert.equal( + overlayNotifications[0]?.image, + `data:image/png;base64,${Buffer.from('png').toString('base64')}`, + ); + assert.deepEqual(desktopNotifications, [ + { + title: 'Anki Card Updated', + body: 'Updated card: 食べる', + icon: undefined, + }, + ]); + assert.deepEqual(cleanupPaths, []); +}); + +test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => { + const osdMessages: string[] = []; + const desktopMessages: string[] = []; + const overlayMessages: string[] = []; + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'both', + }, + }, + {} as never, + {} as never, + (text) => { + osdMessages.push(text); + }, + (title, options) => { + desktopMessages.push(`${title}:${options.body ?? ''}`); + }, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`); + }, + ); + + assert.equal(await integration.createSentenceCard('食べる', 0, 1), false); + + assert.deepEqual(osdMessages, []); + assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']); + assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']); +}); + test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 5a890c5c..56bb4e00 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -29,6 +29,8 @@ import { } from './types/anki'; import { AiConfig } from './types/integrations'; import { MpvClient } from './types/runtime'; +import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification'; +import type { NotificationType, OverlayNotificationPayload } from './types/notification'; import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { @@ -119,6 +121,15 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b ); } +function toOverlayNotificationImageSource(iconBuffer: Buffer): string { + return `data:image/png;base64,${iconBuffer.toString('base64')}`; +} + +interface NotificationIcon { + filePath?: string; + overlayImageSource: string; +} + export class AnkiIntegration { private client: AnkiConnectClient; private mediaGenerator: MediaGenerator; @@ -130,6 +141,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 +179,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 +189,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 +350,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 +402,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 +478,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 +525,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), @@ -525,6 +540,10 @@ export class AnkiIntegration { return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; } + async openNoteInAnki(noteId: number): Promise { + await this.client.openNoteInBrowser(noteId); + } + private isKnownWordCacheEnabled(): boolean { return ( this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true @@ -860,10 +879,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 +894,51 @@ export class AnkiIntegration { }); } + private getNotificationType(): NotificationType { + return this.config.behavior?.notificationType ?? 'osd'; + } + + private shouldUseOsdNotifications(): boolean { + const type = this.getNotificationType(); + return type === 'osd' || type === 'osd-system'; + } + + private shouldUseOverlayNotifications(): boolean { + const type = this.getNotificationType(); + return type === 'overlay' || type === 'both'; + } + private beginUpdateProgress(initialMessage: string): void { + 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 +948,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,51 +1088,89 @@ 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) { - let notificationIconPath: string | undefined; + const shouldShowOverlayNotification = + (type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null; + const shouldShowSystemNotification = + (type === 'system' || type === 'both' || type === 'osd-system') && + this.notificationCallback !== null; + const notificationIcon = + shouldShowOverlayNotification || shouldShowSystemNotification + ? await this.generateNotificationIcon(noteId, shouldShowSystemNotification) + : undefined; - if (this.mpvClient && this.mpvClient.currentVideoPath) { - try { - const timestamp = this.mpvClient.currentTimePos || 0; - const notificationIconSource = await resolveMediaGenerationInputPath( - this.mpvClient, - 'video', - ); - if (!notificationIconSource) { - throw new Error('No media source available for notification icon'); - } - const iconBuffer = await this.mediaGenerator.generateNotificationIcon( - notificationIconSource, - timestamp, - ); - if (iconBuffer && iconBuffer.length > 0) { - notificationIconPath = this.mediaGenerator.writeNotificationIconToFile( + if (shouldShowOverlayNotification && this.overlayNotificationCallback) { + this.overlayNotificationCallback({ + id: 'anki-update-progress', + title: 'Anki Card Updated', + body: message, + ...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}), + variant: errorSuffix === undefined ? 'success' : 'error', + persistent: false, + actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }], + }); + } + + if (shouldShowSystemNotification && this.notificationCallback) { + this.notificationCallback('Anki Card Updated', { + body: message, + icon: notificationIcon?.filePath, + }); + } + + if (notificationIcon) { + if (notificationIcon.filePath) { + this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath); + } + } + } + + private async generateNotificationIcon( + noteId: number, + shouldWriteToFile: boolean, + ): Promise { + if (!this.mpvClient?.currentVideoPath) { + return undefined; + } + + try { + const timestamp = this.mpvClient.currentTimePos || 0; + const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video'); + if (!notificationIconSource) { + throw new Error('No media source available for notification icon'); + } + const iconBuffer = await this.mediaGenerator.generateNotificationIcon( + notificationIconSource, + timestamp, + ); + if (iconBuffer && iconBuffer.length > 0) { + const notificationIcon: NotificationIcon = { + overlayImageSource: toOverlayNotificationImageSource(iconBuffer), + }; + if (shouldWriteToFile) { + try { + notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile( iconBuffer, noteId, ); + } catch (err) { + log.warn('Failed to write notification icon:', (err as Error).message); } - } catch (err) { - log.warn('Failed to generate notification icon:', (err as Error).message); } + return notificationIcon; } - - this.notificationCallback('Anki Card Updated', { - body: message, - icon: notificationIconPath, - }); - - if (notificationIconPath) { - this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath); - } + } catch (err) { + log.warn('Failed to generate notification icon:', (err as Error).message); } + + return undefined; } private showUpdateResult(message: string, success: boolean): void { diff --git a/src/anki-integration/card-creation-manual-update.test.ts b/src/anki-integration/card-creation-manual-update.test.ts index 558fc288..ba76162e 100644 --- a/src/anki-integration/card-creation-manual-update.test.ts +++ b/src/anki-integration/card-creation-manual-update.test.ts @@ -271,3 +271,28 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote assert.equal(updatedFields[0]?.Sentence, '一行目 二行目'); assert.match(updatedFields[0]?.Picture ?? '', /^$/); }); + +test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => { + const statusMessages: string[] = []; + const progressMessages: string[] = []; + const { service } = createManualUpdateService({ + showOsdNotification: (message) => { + statusMessages.push(message); + }, + withUpdateProgress: async (message, action) => { + progressMessages.push(message); + return await action(); + }, + mediaGenerator: { + generateAudio: async () => null, + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }, + }); + + const created = await service.createSentenceCard('テスト', 0, 1); + + assert.equal(created, true); + assert.deepEqual(progressMessages, ['Creating sentence card']); + assert.deepEqual(statusMessages, []); +}); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 9de9bac0..5b09e5e1 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -511,7 +511,6 @@ export class CardCreationService { endTime = startTime + maxMediaDuration; } - this.deps.showOsdNotification('Creating sentence card...'); try { return await this.deps.withUpdateProgress('Creating sentence card', async () => { const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video'); diff --git a/src/anki-integration/ui-feedback.test.ts b/src/anki-integration/ui-feedback.test.ts index b4c2d7e4..9b453050 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,57 @@ 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 defaults to mpv osd when notification type is unset', () => { + const calls: string[] = []; + + showStatusNotification('Card updated', { + getNotificationType: () => undefined, + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => { + calls.push(`overlay:${payload.body}`); + }, + showSystemNotification: (title, options) => { + calls.push(`system:${title}:${options.body}`); + }, + }); + + assert.deepEqual(calls, ['osd:Card updated']); +}); + +test('showStatusNotification does not duplicate system notifications for both', () => { + const calls: string[] = []; + + showStatusNotification('Card updated', { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => { + calls.push(`overlay:${payload.body}`); + }, + showSystemNotification: (title, options) => { + calls.push(`system:${title}:${options.body}`); + }, + }); + + assert.deepEqual(calls, ['overlay:Card updated', 'system:SubMiner:Card updated']); +}); diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts index f9f53d64..efc6f472 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() ?? 'osd'; - if (type === 'osd' || type === 'both') { + if (type === 'none') { + return; + } + + if (type === 'overlay' || type === 'both') { + if (context.showOverlayNotification) { + context.showOverlayNotification({ + title: 'SubMiner', + body: message, + variant: 'info', + }); + } else if (type === 'overlay') { + context.showSystemNotification('SubMiner', { body: message }); + } + } + + if (type === 'osd' || type === 'osd-system') { context.showOsd(message); } - if (type === 'system' || type === 'both') { + if (type === 'system' || type === 'both' || type === 'osd-system') { context.showSystemNotification('SubMiner', { body: message }); } } diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 26535dbc..a71a1f61 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -131,6 +131,15 @@ test('parseArgs captures session action forwarding flags', () => { assert.equal(shouldStartApp(args), true); }); +test('parseArgs captures internal playback feedback command', () => { + const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']); + + assert.equal(args.playbackFeedback, 'You can skip by pressing TAB'); + assert.equal(hasExplicitCommand(args), true); + assert.equal(shouldStartApp(args), true); + assert.equal(commandNeedsOverlayRuntime(args), true); +}); + test('parseArgs ignores non-positive numeric session action counts', () => { const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']); diff --git a/src/cli/args.ts b/src/cli/args.ts index 5855327b..2f078d72 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -43,6 +43,7 @@ export interface CliArgs { playNextSubtitle: boolean; shiftSubDelayPrevLine: boolean; shiftSubDelayNextLine: boolean; + playbackFeedback?: string; cycleRuntimeOptionId?: string; cycleRuntimeOptionDirection?: 1 | -1; sessionAction?: SessionActionDispatchRequest; @@ -150,6 +151,7 @@ export function parseArgs(argv: string[]): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + playbackFeedback: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -296,7 +298,13 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true; else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true; - else if (arg.startsWith('--cycle-runtime-option=')) { + else if (arg.startsWith('--playback-feedback=')) { + const value = arg.slice('--playback-feedback='.length).trim(); + if (value) args.playbackFeedback = value; + } else if (arg === '--playback-feedback') { + const value = readValue(argv[i + 1])?.trim(); + if (value) args.playbackFeedback = value; + } else if (arg.startsWith('--cycle-runtime-option=')) { const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]); if (parsed) { args.cycleRuntimeOptionId = parsed.id; @@ -556,6 +564,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || args.copySubtitleCount !== undefined || @@ -631,6 +640,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.playNextSubtitle && !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && + args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && args.copySubtitleCount === undefined && @@ -697,6 +707,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || args.copySubtitleCount !== undefined || @@ -757,6 +768,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.playNextSubtitle && !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && + args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && args.copySubtitleCount === undefined && @@ -822,6 +834,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.playNextSubtitle || args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || + args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || args.copySubtitleCount !== undefined || diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8ae18f0a..9228e1ca 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -98,6 +98,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); + assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N'); assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.subtitleStyle.backgroundColor, 'transparent'); @@ -152,7 +153,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.stats.autoOpenBrowser, false); assert.equal(config.updates.enabled, true); assert.equal(config.updates.checkIntervalHours, 24); - assert.equal(config.updates.notificationType, 'system'); + assert.equal(config.updates.notificationType, 'both'); assert.equal(config.updates.channel, 'stable'); assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath); assert.equal(config.mpv.backend, 'auto'); @@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => { "updates": { "enabled": false, "checkIntervalHours": 6, - "notificationType": "both", + "notificationType": "osd-system", "channel": "prerelease" } }`, @@ -182,7 +183,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 +213,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 +2814,7 @@ test('template generator includes known keys', () => { ); assert.match( output, - /"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/, + /"notificationType": "both",? \/\/ 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..f602d151 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 }, @@ -101,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick< openControllerSelect: 'Alt+C', openControllerDebug: 'Alt+Shift+C', toggleSubtitleSidebar: 'Backslash', + toggleNotificationHistory: 'CommandOrControl+N', }, secondarySub: { secondarySubLanguages: [], @@ -126,8 +128,11 @@ export const CORE_DEFAULT_CONFIG: Pick< updates: { enabled: true, checkIntervalHours: 24, - notificationType: 'system', + notificationType: 'both', 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..c86a7174 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', @@ -608,5 +622,11 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar, description: 'Accelerator that toggles the subtitle sidebar visibility.', }, + { + path: 'shortcuts.toggleNotificationHistory', + kind: 'string', + defaultValue: defaultConfig.shortcuts.toggleNotificationHistory, + description: 'Accelerator that toggles the overlay notification history panel.', + }, ]; } 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..71f939a0 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -27,7 +27,17 @@ export interface ConfigOptionRegistryEntry { kind: ConfigValueKind; defaultValue: unknown; description: string; + /** + * Complete runtime-valid enum options, including legacy file-config values such as + * `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES. + */ enumValues?: readonly string[]; + /** + * Optional settings UI subset when legacy/runtime-valid enum options should remain + * editable in config files but hidden from new UI choices, for example + * SETTINGS_NOTIFICATION_TYPE_VALUES. + */ + settingsEnumValues?: readonly string[]; runtime?: RuntimeOptionRegistryEntry; } 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..203998cb 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.', ); } @@ -240,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'openCharacterDictionaryManager', 'openRuntimeOptions', 'openJimaku', + 'toggleNotificationHistory', ] as const; for (const key of shortcutKeys) { @@ -323,4 +320,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..a815379a 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', @@ -577,6 +582,7 @@ function subsectionForPath(path: string): string | undefined { if ( leaf === 'toggleVisibleOverlayGlobal' || leaf === 'toggleSubtitleSidebar' || + leaf === 'toggleNotificationHistory' || leaf === 'toggleSecondarySub' || leaf === 'toggleStatsOverlay' || leaf === 'markWatched' @@ -687,6 +693,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 +717,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/app-ready.test.ts b/src/core/services/app-ready.test.ts index d1a225e6..7e91afb1 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -2,6 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup'; +function waitTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + function makeDeps(overrides: Partial = {}) { const calls: string[] = []; const deps = { @@ -277,20 +281,80 @@ test('runAppReadyRuntime does not await background warmups', async () => { releaseWarmup(); }); -test('runAppReadyRuntime starts background warmups before core runtime services', async () => { +test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => { const calls: string[] = []; - const { deps } = makeDeps({ - startBackgroundWarmups: () => { - calls.push('startBackgroundWarmups'); - }, - loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), - createMpvClient: () => calls.push('createMpvClient'), + let releaseYomitan!: () => void; + const yomitanGate = new Promise((resolve) => { + releaseYomitan = resolve; }); + const { deps } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true, + loadYomitanExtension: async () => { + calls.push('loadYomitanExtension:start'); + await yomitanGate; + calls.push('loadYomitanExtension:done'); + }, + handleFirstRunSetup: async () => { + calls.push('handleFirstRunSetup'); + }, + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + } as Partial); + + const readyPromise = runAppReadyRuntime(deps); + await waitTurn(); + + try { + assert.ok(calls.includes('handleFirstRunSetup')); + assert.ok(calls.includes('handleInitialArgs')); + assert.equal(calls.includes('loadYomitanExtension:done'), false); + } finally { + releaseYomitan(); + await readyPromise; + } +}); + +test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => { + const calls: string[] = []; + let releaseYomitan!: () => void; + const yomitanGate = new Promise((resolve) => { + releaseYomitan = resolve; + }); + const { deps } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false, + loadYomitanExtension: async () => { + calls.push('loadYomitanExtension:start'); + await yomitanGate; + calls.push('loadYomitanExtension:done'); + }, + handleInitialArgs: () => { + calls.push('handleInitialArgs'); + }, + } as Partial); + + const readyPromise = runAppReadyRuntime(deps); + await waitTurn(); + + assert.equal(calls.includes('handleInitialArgs'), false); + + releaseYomitan(); + await readyPromise; + + assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs')); +}); + +test('runAppReadyRuntime starts background warmups after overlay startup', async () => { + const { deps, calls } = makeDeps(); await runAppReadyRuntime(deps); - assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition')); - assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient')); + assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups')); + assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs')); }); test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => { diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index f1d8e316..850eca0a 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -51,6 +51,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + playbackFeedback: undefined, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, anilistStatus: false, @@ -252,6 +253,9 @@ function createDeps(overrides: Partial = {}) { showMpvOsd: (text) => { osd.push(text); }, + showPlaybackFeedback: (text) => { + calls.push(`feedback:${text}`); + }, log: (message) => { calls.push(`log:${message}`); }, @@ -493,6 +497,15 @@ test('handleCliCommand reports async mine errors to OSD', async () => { assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom'))); }); +test('handleCliCommand routes playback feedback through configured feedback surface', () => { + const { deps, calls, osd } = createDeps(); + + handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps); + + assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']); + assert.deepEqual(osd, []); +}); + test('handleCliCommand applies socket path and connects on start', () => { const { deps, calls } = createDeps(); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 3dad5ec7..86902ba5 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -106,6 +106,7 @@ export interface CliCommandServiceDeps { hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; showMpvOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; log: (message: string) => void; logDebug: (message: string) => void; warn: (message: string) => void; @@ -128,6 +129,7 @@ interface MpvCliRuntime { setSocketPath: (socketPath: string) => void; getClient: () => MpvClientLike | null; showOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; } interface TexthookerCliRuntime { @@ -295,6 +297,7 @@ export function createCliCommandDepsRuntime( hasMainWindow: options.app.hasMainWindow, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, showMpvOsd: options.mpv.showOsd, + showPlaybackFeedback: options.mpv.showPlaybackFeedback, log: options.log, logDebug: options.logDebug, warn: options.warn, @@ -546,6 +549,9 @@ export function handleCliCommand( 'shiftSubDelayNextLine', 'Shift subtitle delay failed', ); + } else if (args.playbackFeedback) { + const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd; + showFeedback(args.playbackFeedback); } else if (args.cycleRuntimeOptionId !== undefined) { dispatchCliSessionAction( { 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/ipc.test.ts b/src/core/services/ipc.test.ts index f47f8d4b..5c6d7b4d 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async () => {}, @@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: () => {}, @@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: () => {}, @@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, saveControllerPreference: (update) => { @@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async (update) => { @@ -1262,6 +1267,44 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () => ]); }); +test('registerIpcHandlers forwards valid overlay notification actions', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; + registerIpcHandlers( + createRegisterIpcDeps({ + handleOverlayNotificationAction: (( + notificationId: string, + actionId: string, + noteId?: number, + ) => { + actions.push({ notificationId, actionId, noteId }); + }) as IpcServiceDeps['handleOverlayNotificationAction'], + } as Partial), + registrar, + ); + + const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction); + assert.ok(actionHandler); + + actionHandler({}, null); + actionHandler({}, { notificationId: '', actionId: 'install-update' }); + actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 }); + actionHandler( + {}, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 }, + ); + actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' }); + actionHandler( + {}, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, + ); + + assert.deepEqual(actions, [ + { notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined }, + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, + ]); +}); + test('registerIpcHandlers rejects malformed controller preference payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers( @@ -1289,6 +1332,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, saveControllerPreference: async () => {}, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index b3cc09b0..78c8374f 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -53,6 +53,11 @@ export interface IpcServiceDeps { interactive: boolean, senderWindow: ElectronBrowserWindow | null, ) => void; + handleOverlayNotificationAction?: ( + notificationId: string, + actionId: string, + noteId?: number, + ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -80,6 +85,7 @@ export interface IpcServiceDeps { dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; + getOverlayNotificationPosition: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; @@ -223,6 +229,25 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n return parsed; } +function parseOverlayNotificationActionPayload( + payload: unknown, +): { notificationId: string; actionId: string; noteId?: number } | null { + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + const notificationId = record.notificationId; + const actionId = record.actionId; + const noteId = record.noteId; + if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null; + if (typeof actionId !== 'string' || actionId.trim().length === 0) return null; + if ( + noteId !== undefined && + (typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0) + ) { + return null; + } + return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) }; +} + export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; @@ -242,6 +267,11 @@ export interface IpcDepsRuntimeOptions { interactive: boolean, senderWindow: ElectronBrowserWindow | null, ) => void; + handleOverlayNotificationAction?: ( + notificationId: string, + actionId: string, + noteId?: number, + ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -262,6 +292,7 @@ export interface IpcDepsRuntimeOptions { dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; + getOverlayNotificationPosition: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; @@ -312,6 +343,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService onOverlayModalOpened: options.onOverlayModalOpened, onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, onOverlayInteractiveHint: options.onOverlayInteractiveHint, + handleOverlayNotificationAction: options.handleOverlayNotificationAction, openYomitanSettings: options.openYomitanSettings, recordSubtitleMiningContext: options.recordSubtitleMiningContext, quitApp: options.quitApp, @@ -349,6 +381,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), getStatsToggleKey: options.getStatsToggleKey, getMarkWatchedKey: options.getMarkWatchedKey, + getOverlayNotificationPosition: options.getOverlayNotificationPosition, getControllerConfig: options.getControllerConfig, saveControllerConfig: options.saveControllerConfig, saveControllerPreference: options.saveControllerPreference, @@ -473,6 +506,22 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; deps.onOverlayModalOpened(parsedModal, senderWindow); }); + ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => { + const parsedPayload = parseOverlayNotificationActionPayload(payload); + if (!parsedPayload) return; + void Promise.resolve( + deps.handleOverlayNotificationAction?.( + parsedPayload.notificationId, + parsedPayload.actionId, + parsedPayload.noteId, + ), + ).catch((error) => { + console.warn( + 'Failed to handle overlay notification action:', + error instanceof Error ? error.message : String(error), + ); + }); + }); ipc.handle( IPC_CHANNELS.request.youtubePickerResolve, @@ -641,6 +690,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getMarkWatchedKey(); }); + ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => { + return deps.getOverlayNotificationPosition(); + }); + ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => { return deps.getControllerConfig(); }); 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/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 1766a516..1798e3e9 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial = {}): Configured openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, ...overrides, }; } diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 62770bf3..b41e6029 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial = {}): Configur openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, ...overrides, }; } diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index edfc5bcd..6c493de0 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -154,7 +154,127 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on assert.ok(!calls.includes('show')); }); -test('tracked non-macOS overlay stays hidden while tracker is not ready', () => { +test('macOS dismisses overlay loading OSD when tracker recovers', () => { + const { window, calls } = createMainWindowRecorder(); + let trackerWarning = false; + const osdMessages: string[] = []; + const dismissedOsds: string[] = []; + let tracking = false; + let geometry: WindowTrackerStub['getGeometry'] extends () => infer T ? T : never = null; + const tracker: WindowTrackerStub = { + isTracking: () => tracking, + getGeometry: () => geometry, + isTargetWindowFocused: () => tracking, + }; + + const run = () => + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: trackerWarning, + setTrackerNotReadyWarningShown: (shown: boolean) => { + trackerWarning = shown; + }, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: true, + showOverlayLoadingOsd: (message: string) => { + osdMessages.push(message); + }, + dismissOverlayLoadingOsd: () => { + dismissedOsds.push('dismiss'); + }, + } as never); + + run(); + tracking = true; + geometry = { x: 0, y: 0, width: 1280, height: 720 }; + run(); + + assert.deepEqual(osdMessages, ['Overlay loading...']); + assert.deepEqual(dismissedOsds, ['dismiss']); + assert.equal(trackerWarning, false); + assert.ok(calls.includes('show-inactive')); +}); + +test('tracked non-native overlay shows loading OSD until renderer content is visible', () => { + const { window, calls, setContentReady } = createMainWindowRecorder(); + let loadingShown = false; + const osdMessages: string[] = []; + const dismissedOsds: string[] = []; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => true, + }; + + const run = () => + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: loadingShown, + setTrackerNotReadyWarningShown: (shown: boolean) => { + loadingShown = shown; + }, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: false, + showOverlayLoadingOsd: (message: string) => { + osdMessages.push(message); + }, + dismissOverlayLoadingOsd: () => { + dismissedOsds.push('dismiss'); + }, + } as never); + + setContentReady(false); + run(); + run(); + + assert.equal(loadingShown, true); + assert.deepEqual(osdMessages, ['Overlay loading...']); + assert.deepEqual(dismissedOsds, []); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('show-inactive')); + + setContentReady(true); + run(); + + assert.equal(loadingShown, false); + assert.deepEqual(dismissedOsds, ['dismiss']); + assert.ok(calls.includes('show-inactive')); +}); + +test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; const tracker: WindowTrackerStub = { @@ -197,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () => assert.ok(!calls.includes('update-bounds')); assert.ok(!calls.includes('show')); assert.ok(!calls.includes('focus')); - assert.ok(!calls.includes('osd')); + assert.ok(calls.includes('osd')); }); test('non-native passive overlay stays click-through after subsequent visibility updates', () => { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 7402a81f..6888bb67 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -88,6 +88,7 @@ export function updateVisibleOverlayVisibility(args: { isMacOSPlatform?: boolean; isWindowsPlatform?: boolean; showOverlayLoadingOsd?: (message: string) => void; + dismissOverlayLoadingOsd?: () => void; shouldShowOverlayLoadingOsd?: () => boolean; markOverlayLoadingOsdShown?: () => void; resetOverlayLoadingOsdSuppression?: () => void; @@ -310,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: { !args.isWindowsPlatform && (!args.forceMousePassthrough || args.isMacOSPlatform === true); + const isWaitingForOverlayContentReady = (): boolean => { + const hasWebContents = + typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object'; + return ( + !mainWindow.isVisible() && + hasWebContents && + !isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow) + ); + }; + const maybeShowOverlayLoadingOsd = (): void => { - if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { + if (!args.showOverlayLoadingOsd) { return; } if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) { @@ -320,6 +331,9 @@ export function updateVisibleOverlayVisibility(args: { args.showOverlayLoadingOsd('Overlay loading...'); args.markOverlayLoadingOsdShown?.(); }; + const maybeDismissOverlayLoadingOsd = (): void => { + args.dismissOverlayLoadingOsd?.(); + }; const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => { if ( @@ -350,6 +364,7 @@ export function updateVisibleOverlayVisibility(args: { if (!args.visibleOverlayVisible) { args.setTrackerNotReadyWarningShown(false); args.resetOverlayLoadingOsdSuppression?.(); + maybeDismissOverlayLoadingOsd(); if (args.isWindowsPlatform) { clearPendingWindowsOverlayReveal(mainWindow); setOverlayWindowOpacity(mainWindow, 0); @@ -371,7 +386,15 @@ export function updateVisibleOverlayVisibility(args: { args.syncOverlayShortcuts(); return; } - args.setTrackerNotReadyWarningShown(false); + if (isWaitingForOverlayContentReady()) { + if (!args.trackerNotReadyWarningShown) { + args.setTrackerNotReadyWarningShown(true); + maybeShowOverlayLoadingOsd(); + } + } else { + args.setTrackerNotReadyWarningShown(false); + maybeDismissOverlayLoadingOsd(); + } const geometry = args.windowTracker.getGeometry(); if (geometry) { args.updateVisibleOverlayBounds(geometry); @@ -432,6 +455,7 @@ export function updateVisibleOverlayVisibility(args: { (mainWindow.isVisible() || hasRetainedTrackedGeometry) ) { args.setTrackerNotReadyWarningShown(false); + maybeDismissOverlayLoadingOsd(); const geometry = args.windowTracker.getGeometry(); if (geometry) { args.updateVisibleOverlayBounds(geometry); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 6124563c..d13f7e77 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -116,6 +116,7 @@ export function createOverlayWindow( linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void; yomitanSession?: Session | null; @@ -139,6 +140,7 @@ export function createOverlayWindow( window.webContents.on('did-finish-load', () => { window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); + options.onWindowDidFinishLoad?.(); }); window.webContents.on('page-title-updated', (event) => { diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index 89f93a11..cbd6bd03 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -25,6 +25,7 @@ function createDeps(overrides: Partial = {}) { mineSentenceCount: (count) => calls.push(`mine:${count}`), toggleSecondarySub: () => calls.push('secondary'), toggleSubtitleSidebar: () => calls.push('sidebar'), + toggleNotificationHistory: () => calls.push('notification-history'), markLastCardAsAudioCard: async () => { calls.push('audio'); }, diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 14552764..8cc31e36 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps { mineSentenceCount: (count: number) => void; toggleSecondarySub: () => void; toggleSubtitleSidebar: () => void; + toggleNotificationHistory: () => void; markLastCardAsAudioCard: () => Promise; markActiveVideoWatched: () => Promise; openRuntimeOptionsPalette: () => void; @@ -79,6 +80,9 @@ export async function dispatchSessionAction( case 'toggleSubtitleSidebar': deps.toggleSubtitleSidebar(); return; + case 'toggleNotificationHistory': + deps.toggleNotificationHistory(); + return; case 'markAudioCard': await deps.markLastCardAsAudioCard(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 64199b97..e616b047 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial = {}): Configur openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, ...overrides, }; } @@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => { platform: 'win32', }); - assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']); + assert.deepEqual( + result.bindings.map((binding) => binding.sourcePath), + ['keybindings[0].key'], + ); assert.deepEqual( result.warnings.map((warning) => `${warning.kind}:${warning.path}`), ['unsupported:shortcuts.openJimaku'], diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index 23301877..c91fc1bd 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'openControllerSelect', actionId: 'openControllerSelect' }, { key: 'openControllerDebug', actionId: 'openControllerDebug' }, { key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' }, + { key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' }, ]; function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] { diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index a349d60f..cfba8def 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali ]); }); -test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => { +test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => { const calls: string[] = []; await runAppReadyRuntime({ @@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime' shouldSkipHeavyStartup: () => false, }); - assert.ok(calls.indexOf('load-yomitan') !== -1); assert.ok(calls.indexOf('init-overlay') !== -1); - assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay')); + assert.ok(calls.indexOf('warmups') !== -1); + assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups')); + assert.equal(calls.includes('load-yomitan'), false); }); test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => { diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 8f8c5b95..3bc4b7ce 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps { shouldRunHeadlessInitialCommand?: () => boolean; shouldUseMinimalStartup?: () => boolean; shouldSkipHeavyStartup?: () => boolean; + shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean; } const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [ @@ -229,6 +230,31 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise => { + if (firstRunSetupHandled) { + return; + } + firstRunSetupHandled = true; + await deps.handleFirstRunSetup(); + }; + const handleInitialArgsOnce = (): void => { + if (initialArgsHandled) { + return; + } + initialArgsHandled = true; + deps.handleInitialArgs(); + }; + const startBackgroundWarmupsOnce = (): void => { + if (backgroundWarmupsHandled) { + return; + } + backgroundWarmupsHandled = true; + deps.startBackgroundWarmups(); + }; + deps.ensureDefaultConfigBootstrap(); if (deps.shouldRunHeadlessInitialCommand?.()) { deps.reloadConfig(); @@ -247,7 +273,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise, + 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..505e14f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,6 +64,7 @@ import { type ForegroundSuppressionGraceState, mapOverlayMeasurementForPointerInteraction, resolveForegroundSuppressionWithGrace, + shouldPrimeLinuxOverlayInteractionFromMeasurement, tickLinuxOverlayPointerInteraction, } from './main/runtime/linux-overlay-pointer-interaction'; import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point'; @@ -140,9 +141,13 @@ import type { SubtitleData, SubtitleMiningContext, SubtitlePosition, + OverlayNotificationPayload, + OverlayNotificationEventPayload, + NotificationType, UpdateChannel, WindowGeometry, } from './types'; +import { OPEN_ANKI_CARD_ACTION_ID } from './types'; import { AnkiIntegration } from './anki-integration'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { RuntimeOptionsManager } from './runtime-options'; @@ -189,6 +194,7 @@ import { import { AnkiConnectClient } from './anki-connect'; import { getStartupModeFlags, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup, shouldRefreshAnilistOnConfigReload, shouldStartAutomaticUpdateChecks, } from './main/runtime/startup-mode-flags'; @@ -601,7 +607,21 @@ import { } from './main/runtime/update/release-assets'; 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 { + INSTALL_UPDATE_ACTION_ID, + notifyUpdateAvailable, + UPDATE_AVAILABLE_NOTIFICATION_ID, +} from './main/runtime/update/update-notifications'; +import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd'; +import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start'; +import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position'; +import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery'; +import { + getPlaybackFeedbackNotificationOptions, + notifyConfiguredStatus, + type ConfiguredStatusNotificationOptions, +} from './main/runtime/configured-status-notification'; +import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing'; import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; import { runUpdateCliCommand, @@ -1234,7 +1254,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(), @@ -1297,7 +1317,6 @@ const autoplayReadyGate = createAutoplayReadyGate({ broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest); }, isSignalTargetReady: (signal) => - isTokenizationWarmupReady() && isVisibleOverlayAutoplayTargetReady( { getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), @@ -1469,6 +1488,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 +1736,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 +1758,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 +1792,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 +1817,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({ @@ -1861,10 +1905,16 @@ async function resolveSentenceSearchHeadwords(term: string): Promise { function signalCurrentSubtitleAutoplayReady(): void { autoplayReadyGate.flushPendingAutoplayReadySignal(); const payload = getCurrentAutoplaySubtitlePayload(); - if (!payload) { + if (payload) { + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); return; } - autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + if (!appState.currentSubText.trim()) { + autoplayReadyGate.maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + } } const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ @@ -1897,6 +1947,8 @@ let subtitleSidebarRequestedOpen = false; const SEEK_THRESHOLD_SECONDS = 3; const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; let autoplaySubtitlePrimedMediaPath: string | null = null; +let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType | null = null; +const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; function getCurrentAutoplayMediaPath(): string | null { return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; @@ -1971,6 +2023,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise { subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.refreshCurrentSubtitle(text); }, + deferUncachedRefresh: true, emitSubtitle: (payload) => emitSubtitlePayload(payload), setCurrentSecondarySubText: (text) => { if (appState.mpvClient) { @@ -1986,6 +2039,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise { }); } +function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; +} + +function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) { + return; + } + + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + if (!overlayManager.getVisibleOverlayVisible()) { + return; + } + const text = appState.currentSubText; + if (!text.trim()) { + return; + } + subtitlePrefetchService?.pause(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); +} + async function primeAutoplaySubtitleFromParsedCues( mediaPath: string, cues: SubtitleCue[], @@ -2134,7 +2219,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( return windowTracker.isTargetWindowFocused(); }, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredStatusNotification(text), openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, @@ -2177,7 +2262,9 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => { const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( { + getNotificationType: () => getConfiguredStatusNotificationType(), showMpvOsd: (message) => showMpvOsd(message), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), }, ); @@ -2536,8 +2623,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 +2702,10 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( isMacOSPlatform: () => process.platform === 'darwin', isWindowsPlatform: () => process.platform === 'win32', showOverlayLoadingOsd: (message: string) => { - showMpvOsd(message); + showOverlayLoadingStatusNotification(message); + }, + dismissOverlayLoadingOsd: () => { + dismissOverlayLoadingStatusNotification(); }, hideNonNativeOverlayWhenTargetUnfocused: () => shouldRunLinuxOverlayZOrderKeepAlive() && @@ -2643,6 +2734,7 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200; // subtitle pointer interaction. Right after playback starts the overlay can briefly become the // X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s). const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500; +const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500; const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200; let visibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; @@ -2657,6 +2749,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = }; let visibleOverlayInteractionActive = false; let linuxOverlayInputShapeActive = false; +let linuxVisibleOverlayStartupInputPrimed = false; +let linuxVisibleOverlayStartupInputGraceUntilMs = 0; // Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal // region is interactive, so the cursor poll keeps the overlay interactive even when the cursor // moves off measured subtitle/sidebar rects onto the popup. @@ -2679,6 +2773,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa function resetVisibleOverlayInputState(): void { visibleOverlayInteractionActive = false; linuxOverlayInputShapeActive = false; + resetLinuxVisibleOverlayStartupInputPrimer(); linuxOverlayInteractiveHint = false; overlayContentMeasurementStore.clear('visible'); const mainWindow = overlayManager.getMainWindow(); @@ -3151,6 +3246,23 @@ function shouldUseLinuxOverlayInputShape(): boolean { return false; } +function hasLinuxVisibleOverlayStartupInputGrace(): boolean { + return ( + process.platform === 'linux' && + linuxVisibleOverlayStartupInputGraceUntilMs > 0 && + Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs + ); +} + +function clearLinuxVisibleOverlayStartupInputGrace(): void { + linuxVisibleOverlayStartupInputGraceUntilMs = 0; +} + +function resetLinuxVisibleOverlayStartupInputPrimer(): void { + linuxVisibleOverlayStartupInputPrimed = false; + clearLinuxVisibleOverlayStartupInputGrace(); +} + function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean { if (!shouldUseLinuxOverlayInputShape()) { linuxOverlayInputShapeActive = false; @@ -3189,6 +3301,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void { overlayVisibilityRuntime.updateVisibleOverlayVisibility(); } +function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void { + if (process.platform !== 'linux') return; + if (linuxVisibleOverlayStartupInputPrimed) return; + if (shouldUseLinuxOverlayInputShape()) return; + if ( + !shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getMainWindow: () => overlayManager.getMainWindow(), + getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, + shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, + shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, + }) + ) { + return; + } + + linuxVisibleOverlayStartupInputPrimed = true; + linuxVisibleOverlayStartupInputGraceUntilMs = + Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS; + updateLinuxOverlayPointerInteractionActive(true); +} + const linuxOverlayZOrderKeepAliveDeps = { getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getMainWindow: () => overlayManager.getMainWindow(), @@ -3249,7 +3383,8 @@ const linuxOverlayPointerInteractionDeps = { getCursorScreenPoint: () => linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()), getSubtitleMeasurement: getLinuxOverlayPointerMeasurement, - getRendererInteractiveHint: () => linuxOverlayInteractiveHint, + getRendererInteractiveHint: () => + linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(), shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction, shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction, shouldUseInputShape: shouldUseLinuxOverlayInputShape, @@ -3296,6 +3431,177 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { overlayManager.broadcastToOverlayWindows(channel, ...args); } +function isVisibleOverlayContentReady(): boolean { + const overlayWindow = overlayManager.getMainWindow(); + return Boolean( + overlayManager.getVisibleOverlayVisible() && + overlayWindow && + isOverlayWindowReadyForNotification(overlayWindow), + ); +} + +function getConfiguredStatusNotificationType(): NotificationType { + const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType; + return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady()); +} + +function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean { + if (window.isDestroyed() || !isOverlayWindowContentReady(window)) { + return false; + } + if (window.webContents.isLoading()) { + return false; + } + const currentURL = window.webContents.getURL(); + return currentURL !== '' && currentURL !== 'about:blank'; +} + +const overlayNotificationDelivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => isVisibleOverlayContentReady(), + send: (payload) => { + broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); + }, + scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs), + clearFlushRetry: (handle) => clearTimeout(handle as ReturnType), +}); +let overlayLoadingOsdController: ReturnType | null = null; + +function flushQueuedOverlayNotifications(): void { + overlayNotificationDelivery.flush(); +} + +function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void { + overlayNotificationDelivery.send(payload); +} + +function showOverlayNotification(payload: OverlayNotificationPayload): void { + sendOverlayNotificationEvent( + withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()), + ); +} + +function dismissOverlayNotification(id: string): void { + sendOverlayNotificationEvent({ id, dismiss: true }); +} + +async function openAnkiCardFromNotification(noteId: number): Promise { + const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId); + if (activeIntegrationOpen) { + await activeIntegrationOpen; + return; + } + + const resolvedConfig = getResolvedConfig(); + const effectiveAnkiConfig = + appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ?? + resolvedConfig.ankiConnect; + const fallbackClient = new AnkiConnectClient( + effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url, + ); + await fallbackClient.openNoteInBrowser(noteId); +} + +function toggleNotificationHistoryPanel(): void { + broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle); +} + +function showConfiguredStatusNotification( + message: string, + options: ConfiguredStatusNotificationOptions = {}, +): void { + notifyConfiguredStatus( + message, + { + getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + isOverlayReady: () => isVisibleOverlayContentReady(), + showOsd: (text) => showMpvOsd(text), + showOverlayNotification, + showDesktopNotification: (title, notificationOptions) => + showDesktopNotification(title, notificationOptions), + }, + options, + ); +} + +function showConfiguredPlaybackFeedback( + message: string, + options: ConfiguredStatusNotificationOptions = {}, +): void { + showConfiguredStatusNotification(message, { + ...getPlaybackFeedbackNotificationOptions(message), + ...options, + delivery: 'feedback', + }); +} + +function showSubsyncStatusNotification(message: string): void { + const syncing = message.startsWith('Subsync: syncing'); + const failed = message.toLowerCase().includes('failed'); + showConfiguredStatusNotification(message, { + id: 'subsync-status', + title: 'Subsync', + variant: failed ? 'error' : syncing ? 'progress' : 'info', + persistent: syncing, + desktop: !syncing, + }); +} + +function showYoutubeFlowStatusNotification(message: string): void { + const progress = + message.startsWith('Downloading subtitles') || + message.startsWith('Loading subtitles') || + message.startsWith('Getting subtitles') || + message === 'Opening YouTube video'; + showConfiguredStatusNotification(message, { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: progress ? 'progress' : 'info', + persistent: progress, + desktop: !progress, + }); +} + +function getOverlayLoadingOsdController(): ReturnType { + if (!overlayLoadingOsdController) { + overlayLoadingOsdController = createOverlayLoadingOsdController({ + showOsd: (message) => { + showMpvOsd(message); + }, + clearOsd: () => { + sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']); + }, + setInterval: (callback, delayMs) => { + const timer = setInterval(callback, delayMs); + timer.unref?.(); + return timer; + }, + clearInterval: (timer) => { + clearInterval(timer as ReturnType); + }, + }); + } + return overlayLoadingOsdController; +} + +function showOverlayLoadingStatusNotification(message: string): void { + void message; + getOverlayLoadingOsdController().start(); +} + +function dismissOverlayLoadingStatusNotification(): void { + getOverlayLoadingOsdController().stop(); + sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']); + dismissOverlayNotification('overlay-loading-status'); +} + +const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({ + getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(), + isOverlayContentReady: () => isVisibleOverlayContentReady(), + startOverlayLoadingOsd: () => { + showOverlayLoadingStatusNotification('Overlay loading...'); + }, +}); + const buildBroadcastRuntimeOptionsChangedMainDepsHandler = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, @@ -3386,12 +3692,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 +3728,7 @@ function openSessionHelpOverlay(): void { function openCharacterDictionaryManagerOverlay(): void { openCharacterDictionaryManagerWithConfigGate({ isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, - getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + getNotificationType: () => getConfiguredStatusNotificationType(), openManager: () => { openOverlayHostedModalWithOsd( openCharacterDictionaryManagerModalRuntime, @@ -3431,6 +3737,7 @@ function openCharacterDictionaryManagerOverlay(): void { ); }, showOsd: (message) => showMpvOsd(message), + showOverlayNotification, showDesktopNotification: (title, options) => showDesktopNotification(title, options), logWarn: (message, error) => logger.warn(message, error), }); @@ -3454,7 +3761,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 +3946,7 @@ const { void appState.jellyfinRemoteSession?.reportPlaying(payload); }, showMpvOsd: (text) => { - showMpvOsd(text); + showConfiguredStatusNotification(text, { title: 'Jellyfin' }); }, updateCurrentMediaTitle: (title) => { mediaRuntime.updateCurrentMediaTitle(title); @@ -3770,7 +4080,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 +4248,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 +4578,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, @@ -4939,6 +5251,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ shouldUseMinimalStartup: () => getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => + shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs), createImmersionTracker: () => { ensureImmersionTrackerStarted(); }, @@ -5017,7 +5331,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined) const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, - tokenizeSubtitle, + tokenizeSubtitle: tokenizeSubtitleRuntime, createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, @@ -5040,6 +5354,7 @@ const { void reportJellyfinRemoteStopped(); }, onMpvConnected: () => { + maybeStartOverlayLoadingOsd(); if (appState.sessionBindingsInitialized) { sendMpvCommandRuntime(appState.mpvClient, [ 'script-message', @@ -5077,6 +5392,7 @@ const { tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, updateCurrentMediaPath: (path) => { const normalizedPath = path.trim(); + maybeStartOverlayLoadingOsd(normalizedPath); const previousPath = appState.currentMediaPath?.trim() || null; const preserveParsedSubtitleCues = isSameYoutubeMediaPath( normalizedPath, @@ -5332,13 +5648,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 +5707,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 () => { @@ -5424,6 +5748,7 @@ const aniSkipRuntime = createAniSkipRuntime({ showMpvOsd: (text, durationMs) => { appState.mpvClient?.send({ command: ['show-text', text, durationMs] }); }, + showPlaybackFeedback: (text) => showConfiguredPlaybackFeedback(text), logInfo: (message) => logger.info(message), logWarn: (message, error) => logger.warn(message, error), logDebug: (message) => logger.debug(message), @@ -5891,8 +6216,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 +6303,7 @@ const { }, numericShortcutRuntimeMainDeps: { globalShortcut, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs), clearTimer: (timer) => clearTimeout(timer), }, @@ -6214,6 +6538,7 @@ function getUpdateService() { { notificationType: getResolvedConfig().updates.notificationType, version }, { showSystemNotification: (title, body) => showDesktopNotification(title, { body }), + showOverlayNotification, showOsdNotification: (message) => { showMpvOsd(message); }, @@ -6238,7 +6563,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 +6600,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler = createBuildUpdateLastCardFromClipboardMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, readClipboardText: () => clipboard.readText(), - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), updateLastCardFromClipboardCore, }); const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler(); @@ -6294,7 +6619,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler( const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({ getAnkiIntegration: () => appState.ankiIntegration, - showMpvOsd: (text) => showMpvOsd(text), + showMpvOsd: (text) => showConfiguredStatusNotification(text), triggerFieldGroupingCore, }); const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler(); @@ -6303,7 +6628,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 +6639,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 +6653,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 +6662,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 +6673,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 +6716,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 +6855,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 { @@ -6556,6 +6881,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro mineSentenceCount: (count) => handleMineSentenceDigit(count), toggleSecondarySub: () => handleCycleSecondarySubMode(), toggleSubtitleSidebar: () => toggleSubtitleSidebar(), + toggleNotificationHistory: () => toggleNotificationHistoryPanel(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markActiveVideoWatched: async () => { ensureImmersionTrackerStarted(); @@ -6587,12 +6913,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 +6940,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 +6960,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ registration: { runtimeOptions: { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - showMpvOsd: (text: string) => showMpvOsd(text), + showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text), }, mainDeps: { getMainWindow: () => overlayManager.getMainWindow(), @@ -6703,6 +7030,30 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ linuxOverlayInteractiveHint = interactive; applyLinuxOverlayInputShapeFromLatestMeasurement(); }, + handleOverlayNotificationAction: (notificationId, actionId, noteId) => { + if ( + notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID && + actionId === INSTALL_UPDATE_ACTION_ID + ) { + void getUpdateService() + .checkForUpdates({ + source: 'manual', + installWhenAvailable: true, + }) + .catch((error) => { + logger.warn('Failed to install update from overlay notification action:', error); + }); + } + if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) { + void openAnkiCardFromNotification(noteId).catch((error) => { + logger.warn('Failed to open Anki card from overlay notification action:', error); + showConfiguredStatusNotification('Failed to open Anki card in Anki.', { + id: 'open-anki-card-failed', + variant: 'error', + }); + }); + } + }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context), @@ -6714,9 +7065,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ currentSubText: appState.currentSubText, currentSubtitleData: appState.currentSubtitleData, withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), + tokenizeUncached: false, tokenizeSubtitle: tokenizeSubtitleForCurrent ? (text) => tokenizeSubtitleForCurrent(text) : undefined, + onResolvedSubtitle: (payload) => { + appState.currentSubtitleData = payload; + autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true }); + }, }); }, getCurrentSubtitleRaw: () => appState.currentSubText, @@ -6840,6 +7196,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ dispatchSessionAction: (request) => dispatchSessionAction(request), getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, + getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition, getControllerConfig: () => getResolvedConfig().controller, saveControllerConfig: (update) => { const currentRawConfig = configService.getRawConfig(); @@ -6862,7 +7219,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ reportOverlayContentBounds: (payload: unknown) => { if (overlayContentMeasurementStore.report(payload)) { tickLinuxOverlayPointerInteractionNow(); + primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); + scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); } }, getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(), @@ -6970,6 +7329,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, + showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(), getFieldGroupingResolver: () => getFieldGroupingResolver(), @@ -7006,7 +7366,8 @@ 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), + showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text), initializeOverlayRuntime: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), @@ -7231,11 +7592,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa linuxVisibleOverlayWindowMode === 'fullscreen-override', onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(), + onWindowDidFinishLoad: () => { + flushQueuedOverlayNotifications(); + }, onWindowContentReady: () => { + dismissOverlayLoadingStatusNotification(); + flushQueuedOverlayNotifications(); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - if (appState.currentSubText.trim()) { - subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); - } + primeLinuxOverlayPointerInteractionAfterFirstMeasurement(); autoplayReadyGate.flushPendingAutoplayReadySignal(); }, onWindowClosed: (windowKind, window) => { @@ -7274,7 +7638,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 +7786,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, + showOverlayNotification, createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), shouldStartAnkiIntegration: () => @@ -7512,11 +7878,15 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void { ensureOverlayWindowsReadyForVisibilityActions(); if (!visible) { + dismissOverlayLoadingStatusNotification(); autoplayReadyGate.markCurrentMediaAutoplayReady(); + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); resetVisibleOverlayInputState(); } if (visible) { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); @@ -7530,10 +7900,14 @@ function toggleVisibleOverlay(): void { ensureOverlayWindowsReadyForVisibilityActions(); const nextVisible = !overlayManager.getVisibleOverlayVisible(); if (!nextVisible) { + dismissOverlayLoadingStatusNotification(); autoplayReadyGate.markCurrentMediaAutoplayReady(); + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); resetVisibleOverlayInputState(); } else { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); @@ -7544,11 +7918,15 @@ function toggleVisibleOverlay(): void { } function setOverlayVisible(visible: boolean): void { if (!visible) { + dismissOverlayLoadingStatusNotification(); + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); resetVisibleOverlayInputState(); autoplayReadyGate.markCurrentMediaAutoplayReady(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); } if (visible) { + maybeStartOverlayLoadingOsd(); + resetLinuxVisibleOverlayStartupInputPrimer(); restoreVisibleOverlayWindowShapeForShow(); void ensureOverlayMpvSubtitlesHidden(); void primeCurrentSubtitleForVisibleOverlay(); diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 008366bb..8c72b2c7 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -63,6 +63,7 @@ export interface AppReadyRuntimeDepsFactoryInput { shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand']; shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup']; shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup']; + shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: AppReadyRuntimeDeps['shouldHandleInitialArgsBeforeDeferredOverlayWarmup']; } export function createAppLifecycleRuntimeDeps( @@ -133,6 +134,8 @@ export function createAppReadyRuntimeDeps( shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand, shouldUseMinimalStartup: params.shouldUseMinimalStartup, shouldSkipHeavyStartup: params.shouldSkipHeavyStartup, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: + params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup, }; } diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 771c2c52..694888f9 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -11,6 +11,7 @@ export interface CliCommandRuntimeServiceContext { setSocketPath: (socketPath: string) => void; getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient']; showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd']; + showPlaybackFeedback?: CliCommandRuntimeServiceDepsParams['mpv']['showPlaybackFeedback']; getTexthookerPort: () => number; setTexthookerPort: (port: number) => void; getTexthookerWebsocketUrl: () => string | undefined; @@ -74,6 +75,7 @@ function createCliCommandDepsFromContext( setSocketPath: context.setSocketPath, getClient: context.getClient, showOsd: context.showOsd, + showPlaybackFeedback: context.showPlaybackFeedback, }, texthooker: { service: context.texthookerService, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 13abbfa1..edc2f125 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'; @@ -59,6 +60,7 @@ export interface MainIpcRuntimeServiceDepsParams { onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged']; onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint']; + handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; quitApp: IpcDepsRuntimeOptions['quitApp']; @@ -82,6 +84,7 @@ export interface MainIpcRuntimeServiceDepsParams { dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; + getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; @@ -124,6 +127,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']; @@ -145,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams { setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath']; getClient: CliCommandDepsRuntimeOptions['mpv']['getClient']; showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd']; + showPlaybackFeedback?: CliCommandDepsRuntimeOptions['mpv']['showPlaybackFeedback']; }; texthooker: { service: CliCommandDepsRuntimeOptions['texthooker']['service']; @@ -221,6 +226,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']; @@ -240,6 +246,7 @@ export function createMainIpcRuntimeServiceDeps( onOverlayModalOpened: params.onOverlayModalOpened, onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged, onOverlayInteractiveHint: params.onOverlayInteractiveHint, + handleOverlayNotificationAction: params.handleOverlayNotificationAction, onYoutubePickerResolve: params.onYoutubePickerResolve, openYomitanSettings: params.openYomitanSettings, quitApp: params.quitApp, @@ -261,6 +268,7 @@ export function createMainIpcRuntimeServiceDeps( dispatchSessionAction: params.dispatchSessionAction, getStatsToggleKey: params.getStatsToggleKey, getMarkWatchedKey: params.getMarkWatchedKey, + getOverlayNotificationPosition: params.getOverlayNotificationPosition, getControllerConfig: params.getControllerConfig, saveControllerConfig: params.saveControllerConfig, saveControllerPreference: params.saveControllerPreference, @@ -309,6 +317,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, @@ -334,6 +343,7 @@ export function createCliCommandRuntimeServiceDeps( setSocketPath: params.mpv.setSocketPath, getClient: params.mpv.getClient, showOsd: params.mpv.showOsd, + showPlaybackFeedback: params.mpv.showPlaybackFeedback, }, texthooker: { service: params.texthooker.service, @@ -414,6 +424,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/main-wiring.test.ts b/src/main/main-wiring.test.ts index 1c3160d8..b33030e0 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -59,6 +59,50 @@ test('same media path updates do not reset autoplay ready fallback state', () => ); }); +test('mpv startup signals start overlay loading OSD before readiness work', () => { + const source = readMainSource(); + const connectedBlock = source.match( + /onMpvConnected:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/, + )?.groups?.body; + const mediaPathBlock = source.match( + /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)\n restoreMpvSubVisibility:/, + )?.groups?.body; + const setVisibleBlock = source.match( + /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(connectedBlock); + assert.ok(mediaPathBlock); + assert.ok(setVisibleBlock); + assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/); + assert.match( + mediaPathBlock, + /const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/, + ); + assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/); + assert.match( + source, + /function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/, + ); + assert.match( + source, + /function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/, + ); +}); + +test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => { + const source = readMainSource(); + const dismissBlock = source.match( + /function dismissOverlayLoadingStatusNotification\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(dismissBlock); + assert.match( + dismissBlock, + /sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/, + ); +}); + test('manual visible overlay toggles only release current-media autoplay when hiding', () => { const source = readMainSource(); const actionBlock = source.match( @@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi assert.ok(actionBlock); assert.match( actionBlock, - /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, + /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, ); }); @@ -89,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => { assert.ok(setOverlayBlock); assert.match( setVisibleBlock, - /if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, + /if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, ); assert.match( toggleBlock, - /if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, + /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/, ); assert.match( setOverlayBlock, - /if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, + /if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, ); }); @@ -118,6 +162,23 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = ); }); +test('update overlay notification action triggers install flow', () => { + const source = readMainSource(); + + assert.match( + source, + /handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/, + ); + assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/); + assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); + assert.match(source, /installWhenAvailable:\s*true/); + assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/); + assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/); + assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/); + assert.match(source, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/); + assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/); +}); + test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { const source = readMainSource(); const actionBlock = source.match( @@ -160,7 +221,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o ); }); -test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => { +test('startup autoplay release is tied to visible overlay measurement readiness', () => { const source = readMainSource(); const gateBlock = source.match( /const autoplayReadyGate = createAutoplayReadyGate\(\{(?[\s\S]*?)\n\}\);/, @@ -171,7 +232,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu assert.ok(gateBlock); assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/); - assert.match(gateBlock, /isTokenizationWarmupReady\(\)/); + assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/); assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/); assert.match(gateBlock, /getLatestVisibleMeasurement:/); @@ -180,6 +241,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); }); +test('visible overlay content-ready does not tokenize before first measurement', () => { + const source = readMainSource(); + const contentReadyBlock = source.match( + /onWindowContentReady:\s*\(\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + )?.groups?.body; + const measurementBlock = source.match( + /reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + )?.groups?.body; + + assert.ok(contentReadyBlock); + assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/); + assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); + assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); + assert.ok( + contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') < + contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), + ); + assert.ok( + contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') < + contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'), + ); + + assert.ok(measurementBlock); + assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/); + assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/); + assert.ok( + measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') < + measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'), + ); +}); + test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => { const source = readMainSource(); const measurementBlock = source.match( @@ -189,10 +281,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i assert.ok(measurementBlock); assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/); assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/); + assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/); assert.ok( measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') < measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'), ); + assert.ok( + measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') < + measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'), + ); }); test('subtitle sidebar open state is restored for replacement visible overlay windows', () => { @@ -216,11 +313,14 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi assert.match(depsBlock, /subtitleSidebarRequestedOpen/); }); -test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => { +test('warm tokenization release can signal readiness before the first subtitle appears', () => { const source = readMainSource(); const warmReleaseBlock = source.match( /signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?[\s\S]*?)\n\}\);/, )?.groups?.body; + const signalBlock = source.match( + /function signalCurrentSubtitleAutoplayReady\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; const currentPayloadBlock = source.match( /function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?[\s\S]*?)\n\}/, )?.groups?.body; @@ -230,7 +330,12 @@ test('warm tokenization release reuses current subtitle payload instead of synth warmReleaseBlock, /signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/, ); - assert.doesNotMatch(warmReleaseBlock, /__warm__/); + + assert.ok(signalBlock); + assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/); + assert.match(signalBlock, /if \(payload\) \{/); + assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/); + assert.match(signalBlock, /text: '__warm__'/); assert.ok(currentPayloadBlock); assert.match(currentPayloadBlock, /appState\.currentSubtitleData/); @@ -247,7 +352,10 @@ test('stats server Yomitan note creation honors configured Anki server override )?.groups?.body; assert.ok(addYomitanNoteBlock); - assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/); + assert.match( + addYomitanNoteBlock, + /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/, + ); assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); }); @@ -321,6 +429,49 @@ test('manual visible overlay changes notify mpv plugin visibility state', () => assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); }); +test('manual visible overlay hide dismisses loading OSD', () => { + const source = readMainSource(); + const setBlock = source.match( + /function setVisibleOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + const toggleBlock = source.match( + /function toggleVisibleOverlay\(\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + const setOverlayBlock = source.match( + /function setOverlayVisible\(visible: boolean\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(setBlock); + assert.ok(toggleBlock); + assert.ok(setOverlayBlock); + assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/); + assert.match( + toggleBlock, + /if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, + ); + assert.match( + setOverlayBlock, + /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/, + ); +}); + +test('configured overlay notifications require visible ready overlay window', () => { + const source = readMainSource(); + const readinessBlock = source.match( + /function isVisibleOverlayContentReady\(\): boolean \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + const statusBlock = source.match( + /function showConfiguredStatusNotification\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/, + )?.groups?.body; + + assert.ok(readinessBlock); + assert.ok(statusBlock); + assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/); + assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/); + assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/); + assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/); +}); + test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { const source = readMainSource(); const setBlock = source.match( @@ -334,11 +485,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin assert.ok(toggleBlock); assert.match( setBlock, - /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, ); assert.match( toggleBlock, - /else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, + /else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/, ); }); @@ -357,7 +508,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape' assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/); assert.match( setBlock, - /if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, + /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/, ); }); diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 9e68e9cd..1a35d503 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -30,6 +30,7 @@ export interface OverlayVisibilityRuntimeDeps { isMacOSPlatform: () => boolean; isWindowsPlatform: () => boolean; showOverlayLoadingOsd: (message: string) => void; + dismissOverlayLoadingOsd?: () => void; resolveFallbackBounds: () => WindowGeometry; hideNonNativeOverlayWhenTargetUnfocused?: () => boolean; } @@ -80,6 +81,7 @@ export function createOverlayVisibilityRuntimeService( isMacOSPlatform: deps.isMacOSPlatform(), isWindowsPlatform: deps.isWindowsPlatform(), showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message), + dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(), shouldShowOverlayLoadingOsd: () => lastOverlayLoadingOsdAtMs === null || Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS, diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 47bda6bc..57ad173e 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel assert.deepEqual(calls, []); }); -test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => { +test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => { const calls: string[] = []; const attemptedKeys = new Set(); const mediaKey = '/tmp/video.mkv'; @@ -378,5 +378,5 @@ test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after ret assert.equal(calls.includes('update'), false); assert.equal(calls.includes('enqueue'), false); assert.equal(calls.includes('mark-failure'), false); - assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']); + assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']); }); diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index be3b0f7d..cbd81e84 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -194,8 +194,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { return; } - await deps.processNextAnilistRetryUpdate(); + const retryResult = await deps.processNextAnilistRetryUpdate(); if (deps.hasAttemptedUpdateKey(attemptKey)) { + if (retryResult.ok) { + deps.showMpvOsd(retryResult.message); + } return; } diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts index dc975e61..d02fd972 100644 --- a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts +++ b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts @@ -23,6 +23,18 @@ test('notify anilist setup main deps builder maps callbacks', () => { assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); }); +test('notify anilist setup main deps builder preserves optional notification callbacks', () => { + const deps = createBuildNotifyAnilistSetupMainDepsHandler({ + hasMpvClient: () => true, + showMpvOsd: () => {}, + showDesktopNotification: () => {}, + logInfo: () => {}, + })(); + + assert.equal(deps.getNotificationType, undefined); + assert.equal(deps.showOverlayNotification, undefined); +}); + test('consume anilist setup token main deps builder maps callbacks', () => { const calls: string[] = []; const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.ts b/src/main/runtime/anilist-setup-protocol-main-deps.ts index f32ddd45..a9201ec7 100644 --- a/src/main/runtime/anilist-setup-protocol-main-deps.ts +++ b/src/main/runtime/anilist-setup-protocol-main-deps.ts @@ -18,8 +18,12 @@ type RegisterSubminerProtocolClientMainDeps = Parameters< export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { return (): NotifyAnilistSetupMainDeps => ({ + getNotificationType: deps.getNotificationType ? () => deps.getNotificationType?.() : undefined, hasMpvClient: () => deps.hasMpvClient(), showMpvOsd: (message: string) => deps.showMpvOsd(message), + showOverlayNotification: deps.showOverlayNotification + ? (payload) => deps.showOverlayNotification?.(payload) + : undefined, 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/aniskip-runtime.test.ts b/src/main/runtime/aniskip-runtime.test.ts index 0934f309..6d58ae8d 100644 --- a/src/main/runtime/aniskip-runtime.test.ts +++ b/src/main/runtime/aniskip-runtime.test.ts @@ -22,19 +22,21 @@ function createHarness(options?: { buttonKey?: string; metadata?: AniSkipMetadata | (() => Promise); chapterList?: unknown; + playbackFeedback?: boolean; }) { const state = { enabled: options?.enabled ?? true, buttonKey: options?.buttonKey ?? 'TAB', commands: [] as unknown[][], osd: [] as string[], + feedback: [] as string[], resolveCalls: [] as string[], connected: true, timePos: 0, chapterList: options?.chapterList ?? [], }; - const deps: AniSkipRuntimeDeps = { + const deps = { getAniSkipConfig: () => ({ aniskipEnabled: state.enabled, aniskipButtonKey: state.buttonKey, @@ -57,10 +59,17 @@ function createHarness(options?: { showMpvOsd: (text) => { state.osd.push(text); }, + ...(options?.playbackFeedback + ? { + showPlaybackFeedback: (text: string) => { + state.feedback.push(text); + }, + } + : {}), logInfo: () => {}, logWarn: () => {}, logDebug: () => {}, - }; + } satisfies AniSkipRuntimeDeps & { showPlaybackFeedback?: (text: string) => void }; return { runtime: createAniSkipRuntime(deps), state }; } @@ -152,6 +161,19 @@ test('time-pos prompt shows once near intro start', async () => { assert.deepEqual(state.osd, ['You can skip by pressing TAB']); }); +test('prompt and skip messages use playback feedback when configured', async () => { + const { runtime, state } = createHarness({ buttonKey: 'TAB', playbackFeedback: true }); + runtime.handleMediaPathChange({ path: '/media/show.mkv' }); + await flushAsync(); + + runtime.handleTimePosChange({ time: 10.5 }); + state.timePos = 30; + runtime.handleClientMessage({ args: ['subminer-skip-intro'] }); + + assert.deepEqual(state.feedback, ['You can skip by pressing TAB', 'Skipped intro']); + assert.deepEqual(state.osd, []); +}); + test('connection change binds skip key and legacy fallback for custom keys', () => { const { runtime, state } = createHarness({ buttonKey: 'F6' }); runtime.handleConnectionChange({ connected: true }); diff --git a/src/main/runtime/aniskip-runtime.ts b/src/main/runtime/aniskip-runtime.ts index 03fe41b5..edfca633 100644 --- a/src/main/runtime/aniskip-runtime.ts +++ b/src/main/runtime/aniskip-runtime.ts @@ -22,6 +22,7 @@ export interface AniSkipRuntimeDeps { isMpvConnected: () => boolean; getCurrentTimePos: () => number; showMpvOsd: (text: string, durationMs: number) => void; + showPlaybackFeedback?: (text: string) => void; logInfo: (message: string) => void; logWarn: (message: string, error?: unknown) => void; logDebug: (message: string) => void; @@ -53,6 +54,14 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) { return key || DEFAULT_ANISKIP_BUTTON_KEY; } + function showPlaybackFeedback(text: string, durationMs = PROMPT_OSD_DURATION_MS): void { + if (deps.showPlaybackFeedback) { + deps.showPlaybackFeedback(text); + return; + } + deps.showMpvOsd(text, durationMs); + } + function bindSkipKeys(): void { if (!deps.isMpvConnected()) return; const enabled = deps.getAniSkipConfig().aniskipEnabled; @@ -204,23 +213,23 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) { function skipIntroNow(): void { if (!deps.getAniSkipConfig().aniskipEnabled) return; if (!introWindow) { - deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS); + showPlaybackFeedback('Intro skip unavailable'); return; } const now = deps.getCurrentTimePos(); if (!Number.isFinite(now)) { - deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS); + showPlaybackFeedback('Skip unavailable'); return; } if ( now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS || now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS ) { - deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS); + showPlaybackFeedback('Skip intro only during intro'); return; } deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]); - deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS); + showPlaybackFeedback('Skipped intro'); } function handleTimePosChange({ time }: { time: number }): void { @@ -229,7 +238,7 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) { const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end); if (time >= introWindow.start && time < promptWindowEnd) { promptShown = true; - deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS); + showPlaybackFeedback(`You can skip by pressing ${resolveButtonKey()}`); } } diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts index 36438e88..832d58fe 100644 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -48,6 +48,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async startBackgroundWarmups: () => calls.push('start-warmups'), texthookerOnlyMode: false, shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true, setVisibleOverlayVisible: () => calls.push('set-visible-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'), handleInitialArgs: () => calls.push('handle-initial-args'), @@ -64,6 +65,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async assert.equal(onReady.defaultTexthookerPort, 5174); assert.equal(onReady.texthookerOnlyMode, false); assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true); + assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true); assert.equal(onReady.now?.(), 123); onReady.loadSubtitlePosition(); onReady.resolveKeybindings(); diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index 7559fee9..6de17228 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -45,5 +45,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand, shouldUseMinimalStartup: deps.shouldUseMinimalStartup, shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: + deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup, }); } diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index af25162c..0458057a 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -314,6 +314,100 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea ); }); +test('autoplay ready gate retries deferred readiness without an external flush event', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + let targetReady = false; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => true, + send: ({ command }: { command: Array }) => { + commands.push(command); + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + isSignalTargetReady: () => targetReady, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, []); + assert.equal(scheduled.length, 1); + + targetReady = true; + scheduled.shift()?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual( + commands.filter((command) => command[0] === 'script-message'), + [['script-message', 'subminer-autoplay-ready']], + ); + assert.equal( + commands.some( + (command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ), + true, + ); +}); + +test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => true, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => true, + send: ({ command }: { command: Array }) => { + commands.push(command); + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + isSignalTargetReady: () => false, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + for (let attempt = 1; attempt <= 100; attempt += 1) { + assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`); + scheduled.shift()?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + assert.deepEqual(commands, []); +}); + test('autoplay ready gate drops deferred readiness after media changes before flush', async () => { const commands: Array> = []; let targetReady = false; diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index f9c49bf8..65e77a67 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -1,6 +1,9 @@ import type { SubtitleData } from '../../types'; import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy'; +const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200; +const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150; + type MpvClientLike = { connected?: boolean; requestProperty: (property: string) => Promise; @@ -34,12 +37,22 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { let autoPlayReadySignalMediaPath: string | null = null; let autoPlayReadySignalGeneration = 0; let pendingAutoplayReadySignal: AutoplayReadySignal | null = null; + let pendingAutoplayReadyRetryToken = 0; + let pendingAutoplayReadyRetryAttempts = 0; + let scheduledPendingAutoplayReadyRetryToken: number | null = null; const now = deps.now ?? (() => Date.now()); + const invalidatePendingAutoplayReadyRetry = (): void => { + pendingAutoplayReadyRetryToken += 1; + pendingAutoplayReadyRetryAttempts = 0; + scheduledPendingAutoplayReadyRetryToken = null; + }; + const invalidatePendingAutoplayReadyFallbacks = (): void => { autoPlayReadySignalMediaPath = null; pendingAutoplayReadySignal = null; autoPlayReadySignalGeneration += 1; + invalidatePendingAutoplayReadyRetry(); }; const isSignalTargetReady = (signal: AutoplayReadySignal): boolean => @@ -52,18 +65,43 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { pendingAutoplayReadySignal = null; autoPlayReadySignalMediaPath = getSignalMediaPath(); autoPlayReadySignalGeneration += 1; + invalidatePendingAutoplayReadyRetry(); }; - const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => { + const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => { if ( pendingAutoplayReadySignal && pendingAutoplayReadySignal.mediaPath === signal.mediaPath && pendingAutoplayReadySignal.payload.text === signal.payload.text && pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs ) { - return; + return false; } pendingAutoplayReadySignal = signal; + pendingAutoplayReadyRetryAttempts = 0; + return true; + }; + + const schedulePendingAutoplayReadyRetry = (): void => { + if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) { + return; + } + if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) { + return; + } + + const retryToken = pendingAutoplayReadyRetryToken; + pendingAutoplayReadyRetryAttempts += 1; + scheduledPendingAutoplayReadyRetryToken = retryToken; + deps.schedule(() => { + if (scheduledPendingAutoplayReadyRetryToken === retryToken) { + scheduledPendingAutoplayReadyRetryToken = null; + } + if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) { + return; + } + flushPendingAutoplayReadySignal(); + }, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS); }; const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => { @@ -139,6 +177,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { }; pendingAutoplayReadySignal = null; + invalidatePendingAutoplayReadyRetry(); autoPlayReadySignalMediaPath = mediaPath; const playbackGeneration = ++autoPlayReadySignalGeneration; deps.signalPluginAutoplayReady(); @@ -152,10 +191,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { return; } if (!isSignalTargetReady(signal)) { - setPendingAutoplayReadySignal(signal); - deps.logDebug( - `[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`, - ); + const pendingSignalChanged = setPendingAutoplayReadySignal(signal); + schedulePendingAutoplayReadyRetry(); + if (pendingSignalChanged) { + deps.logDebug( + `[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`, + ); + } 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..8f97ecdf 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -4,6 +4,7 @@ import { notifyCharacterDictionaryAutoSyncStatus, type CharacterDictionaryAutoSyncNotificationEvent, } from './character-dictionary-auto-sync-notifications'; +import { createStartupOsdSequencer } from './startup-osd-sequencer'; function makeEvent( phase: CharacterDictionaryAutoSyncNotificationEvent['phase'], @@ -70,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => { ]); }); -test('auto sync notifications never send desktop notifications', () => { +test('auto sync notifications send overlay and desktop delivery for both', () => { const calls: string[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { @@ -80,14 +81,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.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, + ), }); notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), { getNotificationType: () => 'both', @@ -96,9 +93,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.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`, + ), }); - notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), { - getNotificationType: () => 'both', + + assert.deepEqual(calls, [ + 'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin', + 'desktop:SubMiner:syncing', + 'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto', + 'desktop:SubMiner:ready', + ]); +}); + +test('auto sync notifications fall back to desktop when overlay routing is unavailable', () => { + const calls: string[] = []; + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('building', 'building'), { + getNotificationType: () => undefined, showOsd: (message) => { calls.push(`osd:${message}`); }, @@ -106,14 +119,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 +151,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 +160,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}`); }, @@ -154,3 +188,29 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']); }); + +test('auto sync notifications let startup sequencer own osd-system desktop delivery', () => { + const calls: string[] = []; + const startupOsdSequencer = createStartupOsdSequencer({ + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => { + calls.push(`desktop:${title}:${options.body ?? ''}`); + }, + }); + startupOsdSequencer.markTokenizationReady(); + + notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), { + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`direct-osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`direct-desktop:${title}:${options.body ?? ''}`), + startupOsdSequencer, + }); + + assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']); +}); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 3610c951..5496746b 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -1,11 +1,14 @@ import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync'; import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer'; +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; +import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing'; export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent; export 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 +17,58 @@ export interface CharacterDictionaryAutoSyncNotificationDeps { }; } -function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean { - return type !== 'none'; +function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean { + return phase === 'ready' || phase === 'failed'; } -function shouldFallbackToDesktop( - type: 'osd' | 'system' | 'both' | 'none' | undefined, +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'; +} + +function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string { + const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current'; + return `character-dictionary-auto-sync-${mediaId}-${event.phase}`; } export function notifyCharacterDictionaryAutoSyncStatus( 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; + let startupSequencerShown = false; + + if (shouldShowOverlay(type)) { + if (deps.showOverlayNotification) { + deps.showOverlayNotification({ + id: 'character-dictionary-auto-sync', + historyId: historyIdForEvent(event), + title: 'Character dictionary', + body: event.message, + variant: overlayVariantForPhase(event.phase), + persistent: !isTerminalPhase(event.phase), }); - 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) { + startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ + phase: event.phase, + message: event.message, + }); + } else { + deps.showOsd(event.message); + } + } + + if (shouldShowDesktop(type) && !startupSequencerShown) { + 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/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 4ebd0a38..d921c869 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -7,6 +7,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { setSocketPath: (socketPath: string) => void; getMpvClient: CliCommandContextFactoryDeps['getMpvClient']; showOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; texthookerService: CliCommandContextFactoryDeps['texthookerService']; getTexthookerPort: () => number; setTexthookerPort: (port: number) => void; @@ -63,6 +64,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { setSocketPath: deps.setSocketPath, getMpvClient: deps.getMpvClient, showOsd: deps.showOsd, + showPlaybackFeedback: deps.showPlaybackFeedback, texthookerService: deps.texthookerService, getTexthookerPort: deps.getTexthookerPort, setTexthookerPort: deps.setTexthookerPort, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index d50bf87f..19de1f26 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -25,6 +25,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { openExternal: (url: string) => Promise; logBrowserOpenError: (url: string, error: unknown) => void; showMpvOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; initializeOverlayRuntime: () => void; toggleVisibleOverlay: () => void; @@ -83,6 +84,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { }, getMpvClient: () => deps.appState.mpvClient, showOsd: (text: string) => deps.showMpvOsd(text), + showPlaybackFeedback: deps.showPlaybackFeedback, texthookerService: deps.texthookerService, getTexthookerPort: () => deps.appState.texthookerPort, setTexthookerPort: (port: number) => { diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index e622cf25..e1a36cd3 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -12,6 +12,7 @@ export type CliCommandContextFactoryDeps = { setSocketPath: (socketPath: string) => void; getMpvClient: () => MpvClientLike; showOsd: (text: string) => void; + showPlaybackFeedback?: (text: string) => void; texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService']; getTexthookerPort: () => number; setTexthookerPort: (port: number) => void; @@ -72,6 +73,7 @@ export function createCliCommandContext( setSocketPath: deps.setSocketPath, getClient: deps.getMpvClient, showOsd: deps.showOsd, + showPlaybackFeedback: deps.showPlaybackFeedback, texthookerService: deps.texthookerService, getTexthookerPort: deps.getTexthookerPort, setTexthookerPort: deps.setTexthookerPort, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 8cb26a7c..7f977613 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -58,6 +58,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', + getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => ({}) as never, saveControllerConfig: () => {}, saveControllerPreference: () => {}, 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..174ed52a --- /dev/null +++ b/src/main/runtime/configured-status-notification.test.ts @@ -0,0 +1,439 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getPlaybackFeedbackNotificationOptions, + notifyConfiguredStatus, +} from './configured-status-notification'; +import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; + +test('notifyConfiguredStatus routes both to overlay and system without osd', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Subsync: choose engine and source', { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push( + `overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`, + ), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, [ + 'overlay::SubMiner:Subsync: choose engine and source:info:auto', + 'desktop:SubMiner:Subsync: choose engine and source', + ]); +}); + +test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay loading...', { + getNotificationType: () => 'both', + isOverlayReady: () => false, + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); +}); + +test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay loading...', { + getNotificationType: () => 'overlay', + isOverlayReady: () => false, + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); +}); + +test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay loading...', { + getNotificationType: () => 'system', + isOverlayReady: () => false, + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']); +}); + +test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay loading...', { + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']); +}); + +test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => { + const calls: string[] = []; + + notifyConfiguredStatus( + 'Subsync: syncing |', + { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push( + `overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`, + ), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }, + { + id: 'subsync-status', + title: 'Subsync', + variant: 'progress', + persistent: true, + desktop: false, + }, + ); + + assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']); +}); + +test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => { + const calls: string[] = []; + + notifyConfiguredStatus( + 'Primary subtitle: hover', + { + getNotificationType: () => 'both', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showOverlayNotification: (payload) => + calls.push(`overlay:${payload.title}:${payload.body ?? ''}`), + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }, + { delivery: 'feedback' }, + ); + + assert.deepEqual(calls, ['overlay:SubMiner:Primary subtitle: hover']); +}); + +test('notifyConfiguredStatus routes osd-system feedback through osd only', () => { + const calls: string[] = []; + + notifyConfiguredStatus( + 'Secondary subtitle: visible', + { + getNotificationType: () => 'osd-system', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }, + { delivery: 'feedback' }, + ); + + assert.deepEqual(calls, ['osd:Secondary subtitle: visible']); +}); + +test('notifyConfiguredStatus suppresses system-only feedback', () => { + const calls: string[] = []; + + notifyConfiguredStatus( + 'Primary subtitle: visible', + { + getNotificationType: () => 'system', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }, + { delivery: 'feedback' }, + ); + + assert.deepEqual(calls, []); +}); + +test('playback feedback options reuse subtitle mode notification ids', () => { + assert.deepEqual(getPlaybackFeedbackNotificationOptions('Primary subtitle: hover'), { + id: 'primary-subtitle-mode-feedback', + }); + assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle: hidden'), { + id: 'secondary-subtitle-mode-feedback', + }); + assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {}); +}); + +test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => { + const calls: string[] = []; + + notifyConfiguredStatus('Overlay unavailable.', { + getNotificationType: () => 'overlay', + showOsd: (message) => { + calls.push(`osd:${message}`); + }, + showDesktopNotification: (title, options) => + calls.push(`desktop:${title}:${options.body ?? ''}`), + }); + + assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']); +}); + +test('overlay notification delivery queues until an overlay window is ready', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + }); + + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); + delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' }); + + assert.equal(delivery.getQueuedCount(), 2); + assert.deepEqual(sent, []); + + ready = true; + delivery.flush(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, [ + 'startup-tokenization:Loading', + 'character-dictionary-auto-sync:Building', + ]); +}); + +test('overlay notification delivery upserts queued progress by notification id', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + }); + + delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' }); + delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' }); + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']); +}); + +test('overlay notification delivery preserves queued events with distinct history ids', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`, + ), + }); + + delivery.send({ + id: 'character-dictionary-auto-sync', + historyId: 'character-dictionary-auto-sync-checking', + title: 'Character dictionary', + body: 'Checking character dictionary...', + persistent: true, + }); + delivery.send({ + id: 'character-dictionary-auto-sync', + historyId: 'character-dictionary-auto-sync-building', + title: 'Character dictionary', + body: 'Building character dictionary...', + persistent: true, + }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, [ + 'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...', + 'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...', + ]); +}); + +test('overlay notification delivery preserves queued startup progress before terminal update', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, + ), + scheduleFlushRetry: (callback) => { + scheduled.push(callback); + }, + }); + + delivery.send({ + id: 'startup-tokenization', + title: 'Subtitle tokenization', + body: 'Loading subtitle tokenization...', + variant: 'progress', + persistent: true, + }); + delivery.send({ + id: 'startup-tokenization', + title: 'Subtitle tokenization', + body: 'Subtitle tokenization ready', + variant: 'success', + persistent: false, + }); + + ready = true; + delivery.flush(); + scheduled.shift()?.(); + + assert.deepEqual(sent, [ + 'startup-tokenization:Loading subtitle tokenization...:pin', + 'startup-tokenization:Subtitle tokenization ready:auto', + ]); +}); + +test('overlay notification delivery defers terminal update after first queued progress paint', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + const delays: number[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push( + `${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`, + ), + scheduleFlushRetry: (callback, delayMs) => { + scheduled.push(callback); + delays.push(delayMs); + }, + terminalUpdateDelayMs: 750, + }); + + delivery.send({ + id: 'startup-subtitle-annotations', + title: 'Subtitle annotations', + body: 'Loading subtitle annotations |', + variant: 'progress', + persistent: true, + }); + delivery.send({ + id: 'startup-subtitle-annotations', + title: 'Subtitle annotations', + body: 'Subtitle annotations loaded', + variant: 'success', + persistent: false, + }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']); + assert.equal(delivery.getQueuedCount(), 1); + assert.deepEqual(delays, [750]); + + scheduled.shift()?.(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, [ + 'startup-subtitle-annotations:Loading subtitle annotations |:pin', + 'startup-subtitle-annotations:Subtitle annotations loaded:auto', + ]); +}); + +test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => { + const sent: string[] = []; + const scheduled: Array<() => void> = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`), + scheduleFlushRetry: (callback) => { + scheduled.push(callback); + }, + }); + + delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' }); + delivery.flush(); + + assert.equal(delivery.getQueuedCount(), 1); + assert.equal(scheduled.length, 1); + assert.deepEqual(sent, []); + + ready = true; + scheduled.shift()?.(); + + assert.equal(delivery.getQueuedCount(), 0); + assert.deepEqual(sent, ['startup-tokenization:Loading']); +}); + +test('overlay notification delivery drops queued notification when dismissed before flush', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), + }); + + delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); + delivery.send({ id: 'overlay-loading-status', dismiss: true }); + + ready = true; + delivery.flush(); + + assert.deepEqual(sent, []); +}); + +test('overlay notification delivery removes queued notification when dismissed at readiness', () => { + const sent: string[] = []; + let ready = false; + const delivery = createOverlayNotificationDelivery({ + hasReadyOverlayWindow: () => ready, + send: (payload) => + sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`), + }); + + delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' }); + + ready = true; + delivery.send({ id: 'overlay-loading-status', dismiss: true }); + delivery.flush(); + + assert.deepEqual(sent, ['dismiss:overlay-loading-status']); +}); diff --git a/src/main/runtime/configured-status-notification.ts b/src/main/runtime/configured-status-notification.ts new file mode 100644 index 00000000..e9a264e6 --- /dev/null +++ b/src/main/runtime/configured-status-notification.ts @@ -0,0 +1,74 @@ +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; +import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing'; + +export interface ConfiguredStatusNotificationDeps { + getNotificationType: () => NotificationType | undefined; + isOverlayReady?: () => boolean; + showOsd: (message: string) => boolean | void; + showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + showDesktopNotification: (title: string, options: { body?: string }) => void; +} + +export interface ConfiguredStatusNotificationOptions { + id?: string; + title?: string; + variant?: OverlayNotificationPayload['variant']; + persistent?: boolean; + desktop?: boolean; + delivery?: 'notification' | 'feedback'; +} + +export function getPlaybackFeedbackNotificationOptions( + message: string, +): ConfiguredStatusNotificationOptions { + if (/^Primary subtitle: (hidden|visible|hover)$/.test(message)) { + return { id: 'primary-subtitle-mode-feedback' }; + } + if (/^Secondary subtitle: (hidden|visible|hover)$/.test(message)) { + return { id: 'secondary-subtitle-mode-feedback' }; + } + return {}; +} + +export function notifyConfiguredStatus( + message: string, + deps: ConfiguredStatusNotificationDeps, + options: ConfiguredStatusNotificationOptions = {}, +): void { + const type = deps.getNotificationType() ?? 'overlay'; + const delivery = options.delivery ?? 'notification'; + const showOverlay = shouldShowOverlay(type); + const showOsd = shouldShowOsd(type); + const desktopEnabled = delivery !== 'feedback' && options.desktop !== false; + + if (type === 'none') { + return; + } + + if (delivery === 'feedback' && !showOverlay && !showOsd) { + return; + } + + if (showOverlay) { + const overlayReady = deps.isOverlayReady?.() ?? true; + if (deps.showOverlayNotification && overlayReady) { + deps.showOverlayNotification({ + id: options.id, + title: options.title ?? 'SubMiner', + body: message, + variant: options.variant ?? 'info', + persistent: options.persistent ?? false, + }); + } else if (desktopEnabled && !shouldShowDesktop(type)) { + deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message }); + } + } + + if (showOsd) { + deps.showOsd(message); + } + + if (desktopEnabled && shouldShowDesktop(type)) { + deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message }); + } +} diff --git a/src/main/runtime/current-subtitle-snapshot.test.ts b/src/main/runtime/current-subtitle-snapshot.test.ts index 5f6590ea..b1c18f5a 100644 --- a/src/main/runtime/current-subtitle-snapshot.test.ts +++ b/src/main/runtime/current-subtitle-snapshot.test.ts @@ -62,6 +62,40 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token assert.deepEqual(payload.tokens, [{ text: '新' }]); }); +test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => { + let tokenizerCalled = false; + const payload = await resolveCurrentSubtitleForRenderer({ + currentSubText: 'まだキャッシュされていない字幕', + currentSubtitleData: null, + withCurrentSubtitleTiming: withTiming, + tokenizeUncached: false, + tokenizeSubtitle: async (text) => { + tokenizerCalled = true; + return { text, tokens: [{ text: 'ま' } as never] }; + }, + }); + + assert.equal(tokenizerCalled, false); + assert.equal(payload.text, 'まだキャッシュされていない字幕'); + assert.equal(payload.startTime, 1); + assert.equal(payload.tokens, null); +}); + +test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => { + const resolvedPayloads: SubtitleData[] = []; + const payload = await resolveCurrentSubtitleForRenderer({ + currentSubText: '起動字幕', + currentSubtitleData: null, + withCurrentSubtitleTiming: withTiming, + tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }), + onResolvedSubtitle: (resolved) => { + resolvedPayloads.push(resolved); + }, + }); + + assert.deepEqual(resolvedPayloads, [payload]); +}); + test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => { const calls: string[] = []; @@ -84,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']); }); +test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => { + const calls: string[] = []; + + await primeVisibleOverlaySubtitleFromMpv({ + getMpvClient: () => ({ + connected: true, + requestProperty: async (name) => { + calls.push(`request:${name}`); + return '国内外から'; + }, + }), + setCurrentSubText: (text) => calls.push(`set:${text}`), + getCurrentSubtitleData: () => null, + consumeCachedSubtitle: () => null, + onSubtitleChange: (text) => calls.push(`change:${text}`), + refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`), + emitSubtitle: (payload) => calls.push(`emit:${payload.text}`), + deferUncachedRefresh: true, + }); + + assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']); +}); + test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => { const calls: string[] = []; const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] }; diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts index 4f426b57..09c3beb3 100644 --- a/src/main/runtime/current-subtitle-snapshot.ts +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -10,24 +10,34 @@ export async function resolveCurrentSubtitleForRenderer(deps: { currentSubtitleData: SubtitleData | null; withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; tokenizeSubtitle?: (text: string) => Promise; + tokenizeUncached?: boolean; + onResolvedSubtitle?: (payload: SubtitleData) => void; }): Promise { + const resolve = (payload: SubtitleData): SubtitleData => { + const timedPayload = deps.withCurrentSubtitleTiming(payload); + deps.onResolvedSubtitle?.(timedPayload); + return timedPayload; + }; + if (deps.currentSubtitleData?.text === deps.currentSubText) { - return deps.withCurrentSubtitleTiming(deps.currentSubtitleData); + return resolve(deps.currentSubtitleData); } if (!deps.currentSubText.trim()) { - return deps.withCurrentSubtitleTiming({ + return resolve({ text: deps.currentSubText, tokens: null, }); } - const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); - if (tokenized) { - return deps.withCurrentSubtitleTiming(tokenized); + if (deps.tokenizeUncached !== false) { + const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText); + if (tokenized) { + return resolve(tokenized); + } } - return deps.withCurrentSubtitleTiming({ + return resolve({ text: deps.currentSubText, tokens: null, }); @@ -41,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: { onSubtitleChange: (text: string) => void; refreshCurrentSubtitle: (text: string) => void; emitSubtitle: (payload: SubtitleData) => void; + deferUncachedRefresh?: boolean; setCurrentSecondarySubText?: (text: string) => void; emitSecondarySubtitle?: (text: string) => void; logDebug?: (message: string) => void; @@ -107,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: { return; } + if (deps.deferUncachedRefresh === true) { + await primeSecondarySubtitle(); + return; + } + deps.refreshCurrentSubtitle(text); await primeSecondarySubtitle(); } diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index 53655401..92ae205f 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts { openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, }; } diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index c7665724..a1ec5995 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -27,6 +27,7 @@ function createShortcuts(): ConfiguredShortcuts { openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, }; } 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/linux-overlay-pointer-interaction.test.ts b/src/main/runtime/linux-overlay-pointer-interaction.test.ts index 0769972d..04ad8b2f 100644 --- a/src/main/runtime/linux-overlay-pointer-interaction.test.ts +++ b/src/main/runtime/linux-overlay-pointer-interaction.test.ts @@ -10,6 +10,7 @@ import { resolveDesiredOverlayInteractive, resolveForegroundSuppressionWithGrace, shouldSuppressPointerInteractionForForegroundWindow, + shouldPrimeLinuxOverlayInteractionFromMeasurement, tickLinuxOverlayPointerInteraction, } from './linux-overlay-pointer-interaction'; @@ -136,6 +137,59 @@ test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without ); }); +test('shouldPrimeLinuxOverlayInteractionFromMeasurement primes input from first measured rect', () => { + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => true, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => ({ + ...MEASUREMENT, + interactiveRects: [{ x: 900, y: 900, width: 320, height: 80 }], + }), + shouldSuspend: () => false, + shouldSuppressInteraction: () => false, + }), + true, + ); +}); + +test('shouldPrimeLinuxOverlayInteractionFromMeasurement skips hidden or empty startup surfaces', () => { + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => false, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => MEASUREMENT, + shouldSuspend: () => false, + }), + false, + ); + assert.equal( + shouldPrimeLinuxOverlayInteractionFromMeasurement({ + getVisibleOverlayVisible: () => true, + getMainWindow: () => ({ + isDestroyed: () => false, + isVisible: () => true, + getBounds: () => BOUNDS, + }), + getSubtitleMeasurement: () => ({ + viewport: MEASUREMENT.viewport, + contentRect: null, + interactiveRects: [], + }), + shouldSuspend: () => false, + }), + false, + ); +}); + test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => { const mapped = mapOverlayMeasurementForPointerInteraction({ layer: 'visible', diff --git a/src/main/runtime/linux-overlay-pointer-interaction.ts b/src/main/runtime/linux-overlay-pointer-interaction.ts index c78c3b1c..ee44f8c5 100644 --- a/src/main/runtime/linux-overlay-pointer-interaction.ts +++ b/src/main/runtime/linux-overlay-pointer-interaction.ts @@ -146,6 +146,29 @@ function measuredRectsForInput(measurement: OverlayContentMeasurementLike): Poin : []; } +function hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean { + return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0); +} + +export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: { + getVisibleOverlayVisible: () => boolean; + getMainWindow: () => PointerInteractionWindow | null; + getSubtitleMeasurement: () => OverlayContentMeasurementLike; + shouldSuspend: () => boolean; + shouldSuppressInteraction?: () => boolean; +}): boolean { + if (!deps.getVisibleOverlayVisible()) return false; + if (deps.shouldSuspend()) return false; + + const mainWindow = deps.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { + return false; + } + + if (deps.shouldSuppressInteraction?.()) return false; + return hasMeasuredInputRects(deps.getSubtitleMeasurement()); +} + function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null { const left = Math.max(0, Math.floor(rect.x)); const top = Math.max(0, Math.floor(rect.y)); diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index 781b6078..4b2b3277 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -183,6 +183,34 @@ test('media path change handler signals autoplay readiness from warm media path' ]); }); +test('media path change handler schedules character dictionary once per media path', () => { + const calls: string[] = []; + const handler = createHandleMpvMediaPathChangeHandler({ + updateCurrentMediaPath: (path) => calls.push(`path:${path}`), + reportJellyfinRemoteStopped: () => calls.push('stopped'), + restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`), + maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), + ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), + syncImmersionMediaState: () => calls.push('sync'), + scheduleCharacterDictionarySync: () => calls.push('dict-sync'), + refreshDiscordPresence: () => calls.push('presence'), + }); + + handler({ path: '/tmp/video.mkv' }); + handler({ path: '/tmp/video.mkv' }); + handler({ path: '/tmp/next-video.mkv' }); + handler({ path: '' }); + handler({ path: '/tmp/video.mkv' }); + + assert.deepEqual( + calls.filter((call) => call === 'dict-sync'), + ['dict-sync', 'dict-sync', 'dict-sync'], + ); +}); + test('media path change handler marks Jellyfin remote playback loaded from media path', () => { const calls: string[] = []; const handler = createHandleMpvMediaPathChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 570320eb..4b715e44 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -74,9 +74,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: { flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void; refreshDiscordPresence: () => void; }) { + let lastCharacterDictionarySyncMediaPath: string | null = null; + return ({ path }: { path: string | null }): void => { const normalizedPath = typeof path === 'string' ? path : ''; - if (!normalizedPath) { + const trimmedPath = normalizedPath.trim(); + if (!trimmedPath) { + lastCharacterDictionarySyncMediaPath = null; deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath); } deps.updateCurrentMediaPath(normalizedPath); @@ -92,9 +96,12 @@ export function createHandleMpvMediaPathChangeHandler(deps: { deps.ensureAnilistMediaGuess(mediaKey); } deps.syncImmersionMediaState(); - if (normalizedPath.trim().length > 0) { + if (trimmedPath.length > 0) { deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath); - deps.scheduleCharacterDictionarySync?.(); + if (trimmedPath !== lastCharacterDictionarySyncMediaPath) { + lastCharacterDictionarySyncMediaPath = trimmedPath; + deps.scheduleCharacterDictionarySync?.(); + } deps.signalAutoplayReadyIfWarm?.(normalizedPath); } deps.refreshDiscordPresence(); diff --git a/src/main/runtime/notification-routing.test.ts b/src/main/runtime/notification-routing.test.ts new file mode 100644 index 00000000..736689cc --- /dev/null +++ b/src/main/runtime/notification-routing.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveOverlayReadinessNotificationType, + shouldShowDesktop, + shouldShowOverlay, + shouldShowOsd, +} from './notification-routing'; + +test('notification routing preserves system notification while overlay is not ready', () => { + assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system'); +}); + +test('notification routing preserves both while overlay is not ready', () => { + assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both'); +}); + +test('notification routing preserves overlay-only notification while overlay is not ready', () => { + assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay'); +}); + +test('notification routing predicates classify delivery channels', () => { + assert.equal(shouldShowOverlay('both'), true); + assert.equal(shouldShowOverlay('system'), false); + assert.equal(shouldShowOsd('osd-system'), true); + assert.equal(shouldShowOsd('both'), false); + assert.equal(shouldShowDesktop('osd-system'), true); + assert.equal(shouldShowDesktop('overlay'), false); +}); diff --git a/src/main/runtime/notification-routing.ts b/src/main/runtime/notification-routing.ts new file mode 100644 index 00000000..b6844202 --- /dev/null +++ b/src/main/runtime/notification-routing.ts @@ -0,0 +1,20 @@ +import type { NotificationType } from '../../types/notification'; + +export function shouldShowOsd(type: NotificationType): boolean { + return type === 'osd' || type === 'osd-system'; +} + +export function shouldShowOverlay(type: NotificationType): boolean { + return type === 'overlay' || type === 'both'; +} + +export function shouldShowDesktop(type: NotificationType): boolean { + return type === 'system' || type === 'both' || type === 'osd-system'; +} + +export function resolveOverlayReadinessNotificationType( + type: NotificationType, + _overlayReady: boolean, +): NotificationType { + return type; +} diff --git a/src/main/runtime/overlay-loading-osd-start.test.ts b/src/main/runtime/overlay-loading-osd-start.test.ts new file mode 100644 index 00000000..62fe9437 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd-start.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createMaybeStartOverlayLoadingOsdHandler, + shouldStartOverlayLoadingOsd, +} from './overlay-loading-osd-start'; + +test('overlay loading OSD starts for visible overlay before content is ready', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: false, + }), + true, + ); +}); + +test('overlay loading OSD does not start when hidden or already ready', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: false, + overlayContentReady: false, + }), + false, + ); + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: true, + }), + false, + ); +}); + +test('overlay loading OSD media-path trigger ignores empty paths', () => { + assert.equal( + shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: true, + overlayContentReady: false, + mediaPath: ' ', + }), + false, + ); +}); + +test('overlay loading OSD handler starts idempotent status through injected deps', () => { + const calls: string[] = []; + const maybeStart = createMaybeStartOverlayLoadingOsdHandler({ + getVisibleOverlayRequested: () => true, + isOverlayContentReady: () => false, + startOverlayLoadingOsd: () => { + calls.push('start'); + }, + }); + + maybeStart(); + maybeStart('/tmp/video.mkv'); + maybeStart(' '); + + assert.deepEqual(calls, ['start', 'start']); +}); diff --git a/src/main/runtime/overlay-loading-osd-start.ts b/src/main/runtime/overlay-loading-osd-start.ts new file mode 100644 index 00000000..bfc0f928 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd-start.ts @@ -0,0 +1,32 @@ +export function shouldStartOverlayLoadingOsd(args: { + visibleOverlayRequested: boolean; + overlayContentReady: boolean; + mediaPath?: string | null; +}): boolean { + if (!args.visibleOverlayRequested || args.overlayContentReady) { + return false; + } + if (args.mediaPath !== undefined && (args.mediaPath ?? '').trim().length === 0) { + return false; + } + return true; +} + +export function createMaybeStartOverlayLoadingOsdHandler(deps: { + getVisibleOverlayRequested: () => boolean; + isOverlayContentReady: () => boolean; + startOverlayLoadingOsd: () => void; +}) { + return (mediaPath?: string | null): void => { + if ( + !shouldStartOverlayLoadingOsd({ + visibleOverlayRequested: deps.getVisibleOverlayRequested(), + overlayContentReady: deps.isOverlayContentReady(), + mediaPath, + }) + ) { + return; + } + deps.startOverlayLoadingOsd(); + }; +} diff --git a/src/main/runtime/overlay-loading-osd.test.ts b/src/main/runtime/overlay-loading-osd.test.ts new file mode 100644 index 00000000..291c2ea0 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createOverlayLoadingOsdController } from './overlay-loading-osd'; + +test('overlay loading OSD shows spinner ticks and clears when stopped', () => { + const messages: string[] = []; + const clearedTimers: unknown[] = []; + let tick: (() => void) | null = null; + const controller = createOverlayLoadingOsdController({ + showOsd: (message) => { + messages.push(message); + }, + clearOsd: () => { + messages.push('clear'); + }, + setInterval: (callback) => { + tick = callback; + return 'timer'; + }, + clearInterval: (timer) => { + clearedTimers.push(timer); + }, + }); + + controller.start(); + controller.start(); + + assert.deepEqual(messages, ['Overlay loading |']); + if (!tick) { + assert.fail('expected spinner tick callback'); + } + const tickCallback: () => void = tick; + tickCallback(); + tickCallback(); + + controller.stop(); + controller.stop(); + + assert.deepEqual(messages, ['Overlay loading |', 'Overlay loading /', 'Overlay loading -', 'clear']); + assert.deepEqual(clearedTimers, ['timer']); +}); + diff --git a/src/main/runtime/overlay-loading-osd.ts b/src/main/runtime/overlay-loading-osd.ts new file mode 100644 index 00000000..636bb373 --- /dev/null +++ b/src/main/runtime/overlay-loading-osd.ts @@ -0,0 +1,49 @@ +const DEFAULT_OVERLAY_LOADING_OSD_TICK_MS = 180; +const OVERLAY_LOADING_OSD_FRAMES = ['|', '/', '-', '\\'] as const; + +export function createOverlayLoadingOsdController(deps: { + showOsd: (message: string) => void; + clearOsd: () => void; + setInterval?: (callback: () => void, delayMs: number) => unknown; + clearInterval?: (timer: unknown) => void; +}) { + const setIntervalHandler = + deps.setInterval ?? + ((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs)); + const clearIntervalHandler = + deps.clearInterval ?? + ((timer: unknown): void => clearInterval(timer as ReturnType)); + let active = false; + let frame = 0; + let timer: unknown = null; + + const showNextFrame = (): void => { + deps.showOsd( + `Overlay loading ${OVERLAY_LOADING_OSD_FRAMES[frame % OVERLAY_LOADING_OSD_FRAMES.length]}`, + ); + frame += 1; + }; + + return { + start(): void { + if (active) { + return; + } + active = true; + frame = 0; + showNextFrame(); + timer = setIntervalHandler(showNextFrame, DEFAULT_OVERLAY_LOADING_OSD_TICK_MS); + }, + stop(): void { + if (!active) { + return; + } + active = false; + if (timer !== null) { + clearIntervalHandler(timer); + timer = null; + } + deps.clearOsd(); + }, + }; +} diff --git a/src/main/runtime/overlay-notification-delivery.ts b/src/main/runtime/overlay-notification-delivery.ts new file mode 100644 index 00000000..48cbd1b1 --- /dev/null +++ b/src/main/runtime/overlay-notification-delivery.ts @@ -0,0 +1,158 @@ +import type { OverlayNotificationEventPayload } from '../../types/notification'; + +export interface OverlayNotificationDeliveryDeps { + hasReadyOverlayWindow: () => boolean; + send: (payload: OverlayNotificationEventPayload) => void; + maxQueuedEvents?: number; + flushRetryDelayMs?: number; + terminalUpdateDelayMs?: number; + scheduleFlushRetry?: (callback: () => void, delayMs: number) => unknown; + clearFlushRetry?: (handle: unknown) => void; +} + +function getPayloadId(payload: OverlayNotificationEventPayload): string | null { + return typeof payload.id === 'string' && payload.id.trim().length > 0 ? payload.id : null; +} + +function getPayloadHistoryId(payload: OverlayNotificationEventPayload): string | null { + if ('dismiss' in payload) { + return null; + } + return typeof payload.historyId === 'string' && payload.historyId.trim().length > 0 + ? payload.historyId + : null; +} + +function isDismissPayload( + payload: OverlayNotificationEventPayload, +): payload is Extract { + return 'dismiss' in payload && payload.dismiss === true; +} + +export function createOverlayNotificationDelivery(deps: OverlayNotificationDeliveryDeps): { + send: (payload: OverlayNotificationEventPayload) => void; + flush: () => void; + getQueuedCount: () => number; +} { + const maxQueuedEvents = Math.max(1, deps.maxQueuedEvents ?? 32); + const flushRetryDelayMs = Math.max(1, deps.flushRetryDelayMs ?? 50); + const terminalUpdateDelayMs = Math.max(1, deps.terminalUpdateDelayMs ?? 750); + const queuedEvents: OverlayNotificationEventPayload[] = []; + let flushRetryHandle: unknown = null; + + const removeQueuedPayloadsById = (id: string): void => { + const nextEvents = queuedEvents.filter((queued) => getPayloadId(queued) !== id); + queuedEvents.splice(0, queuedEvents.length, ...nextEvents); + }; + + const clearFlushRetry = (): void => { + if (flushRetryHandle === null) { + return; + } + deps.clearFlushRetry?.(flushRetryHandle); + flushRetryHandle = null; + }; + + const scheduleFlushRetry = (delayMs = flushRetryDelayMs): void => { + if (!deps.scheduleFlushRetry || flushRetryHandle !== null || queuedEvents.length === 0) { + return; + } + flushRetryHandle = deps.scheduleFlushRetry(() => { + flushRetryHandle = null; + flush(); + }, delayMs); + }; + + const queuePayload = (payload: OverlayNotificationEventPayload): void => { + const id = getPayloadId(payload); + if (isDismissPayload(payload)) { + if (id) { + removeQueuedPayloadsById(id); + } + return; + } + + if (id) { + const payloadPersistent = payload.persistent === true; + const payloadHistoryId = getPayloadHistoryId(payload); + const existingIndex = queuedEvents.findIndex( + (queued) => + getPayloadId(queued) === id && + !isDismissPayload(queued) && + getPayloadHistoryId(queued) === payloadHistoryId && + (queued.persistent === true) === payloadPersistent, + ); + if (existingIndex >= 0) { + queuedEvents[existingIndex] = payload; + return; + } + } + + queuedEvents.push(payload); + while (queuedEvents.length > maxQueuedEvents) { + queuedEvents.shift(); + } + }; + + const flush = (): void => { + if (!deps.hasReadyOverlayWindow()) { + scheduleFlushRetry(); + return; + } + clearFlushRetry(); + const readyEvents = queuedEvents.splice(0, queuedEvents.length); + const sentPersistentIds = new Set(); + const deferredTerminalEvents: OverlayNotificationEventPayload[] = []; + for (const payload of readyEvents) { + const id = getPayloadId(payload); + if ( + id && + !isDismissPayload(payload) && + payload.persistent !== true && + sentPersistentIds.has(id) + ) { + deferredTerminalEvents.push(payload); + continue; + } + deps.send(payload); + if (id && !isDismissPayload(payload) && payload.persistent === true) { + sentPersistentIds.add(id); + } + } + if (deferredTerminalEvents.length > 0) { + if (!deps.scheduleFlushRetry) { + for (const payload of deferredTerminalEvents) { + deps.send(payload); + } + return; + } + queuedEvents.unshift(...deferredTerminalEvents); + scheduleFlushRetry(terminalUpdateDelayMs); + } + }; + + const send = (payload: OverlayNotificationEventPayload): void => { + if (isDismissPayload(payload)) { + const id = getPayloadId(payload); + if (id) { + removeQueuedPayloadsById(id); + } + if (deps.hasReadyOverlayWindow()) { + deps.send(payload); + } + return; + } + + if (!deps.hasReadyOverlayWindow()) { + queuePayload(payload); + return; + } + deps.send(payload); + }; + + return { + send, + flush, + getQueuedCount: () => queuedEvents.length, + }; +} 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/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index fcef4244..e48a8f46 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -36,6 +36,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb isMacOSPlatform: () => true, isWindowsPlatform: () => false, showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'), + dismissOverlayLoadingOsd: () => calls.push('dismiss-overlay-loading-osd'), resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }), })(); @@ -60,6 +61,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.isMacOSPlatform(), true); assert.equal(deps.isWindowsPlatform(), false); deps.showOverlayLoadingOsd('Overlay loading...'); + deps.dismissOverlayLoadingOsd?.(); assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 }); assert.equal(trackerNotReadyWarningShown, true); assert.deepEqual(calls, [ @@ -71,5 +73,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb 'enforce-order', 'sync-shortcuts', 'overlay-loading-osd', + 'dismiss-overlay-loading-osd', ]); }); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index e0486d28..63549e04 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -32,6 +32,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( isMacOSPlatform: () => deps.isMacOSPlatform(), isWindowsPlatform: () => deps.isWindowsPlatform(), showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message), + dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(), hideNonNativeOverlayWhenTargetUnfocused: () => deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false, resolveFallbackBounds: () => deps.resolveFallbackBounds(), diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index a63b0b52..8a6a99e6 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -14,6 +14,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; yomitanSession?: Session | null; @@ -29,6 +30,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { getLinuxX11FullscreenOverlay?: () => boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void; getYomitanSession?: () => Session | null; @@ -45,6 +47,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay, onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowFocused: deps.onVisibleWindowFocused, + onWindowDidFinishLoad: deps.onWindowDidFinishLoad, onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, getYomitanSession: () => deps.getYomitanSession?.() ?? null, diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index bb778756..b9300b5e 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -16,6 +16,7 @@ export function createCreateOverlayWindowHandler(deps: { linuxX11FullscreenOverlay?: boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; yomitanSession?: Session | null; @@ -31,6 +32,7 @@ export function createCreateOverlayWindowHandler(deps: { getLinuxX11FullscreenOverlay?: () => boolean; onVisibleWindowBlurred?: () => void; onVisibleWindowFocused?: () => void; + onWindowDidFinishLoad?: () => void; onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void; getYomitanSession?: () => Session | null; @@ -48,6 +50,7 @@ export function createCreateOverlayWindowHandler(deps: { kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined, onVisibleWindowBlurred: deps.onVisibleWindowBlurred, onVisibleWindowFocused: deps.onVisibleWindowFocused, + onWindowDidFinishLoad: deps.onWindowDidFinishLoad, onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, yomitanSession: deps.getYomitanSession?.() ?? null, diff --git a/src/main/runtime/startup-autoplay-release-policy.test.ts b/src/main/runtime/startup-autoplay-release-policy.test.ts index f3bb7082..f25a00f7 100644 --- a/src/main/runtime/startup-autoplay-release-policy.test.ts +++ b/src/main/runtime/startup-autoplay-release-policy.test.ts @@ -12,6 +12,7 @@ test('autoplay release keeps the short retry budget for normal playback signals' }); test('autoplay release uses the full startup timeout window while paused', () => { + assert.equal(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS, 30_000); assert.equal( resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }), Math.ceil(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS), diff --git a/src/main/runtime/startup-autoplay-release-policy.ts b/src/main/runtime/startup-autoplay-release-policy.ts index dabe8463..53606f8a 100644 --- a/src/main/runtime/startup-autoplay-release-policy.ts +++ b/src/main/runtime/startup-autoplay-release-policy.ts @@ -1,5 +1,5 @@ const DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS = 200; -const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 15_000; +const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 30_000; export function resolveAutoplayReadyMaxReleaseAttempts(options?: { forceWhilePaused?: boolean; diff --git a/src/main/runtime/startup-mode-flags.test.ts b/src/main/runtime/startup-mode-flags.test.ts index 95961bab..397b6771 100644 --- a/src/main/runtime/startup-mode-flags.test.ts +++ b/src/main/runtime/startup-mode-flags.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { parseArgs } from '../../cli/args'; import { getStartupModeFlags, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup, shouldRefreshAnilistOnConfigReload, shouldStartAutomaticUpdateChecks, } from './startup-mode-flags'; @@ -25,3 +26,14 @@ test('normal startup still allows background integrations', () => { assert.equal(shouldRefreshAnilistOnConfigReload(null), true); assert.equal(shouldStartAutomaticUpdateChecks(null), true); }); + +test('managed background playback handles initial args before deferred overlay warmup', () => { + const args = parseArgs(['--start', '--background', '--managed-playback']); + + assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(args), true); + assert.equal( + shouldHandleInitialArgsBeforeDeferredOverlayWarmup(parseArgs(['--start', '--background'])), + false, + ); + assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false); +}); diff --git a/src/main/runtime/startup-mode-flags.ts b/src/main/runtime/startup-mode-flags.ts index 86a468ee..910b9ebd 100644 --- a/src/main/runtime/startup-mode-flags.ts +++ b/src/main/runtime/startup-mode-flags.ts @@ -29,6 +29,12 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { }; } +export function shouldHandleInitialArgsBeforeDeferredOverlayWarmup( + initialArgs: CliArgs | null | undefined, +): boolean { + return Boolean(initialArgs?.start && initialArgs.background && initialArgs.managedPlayback); +} + export function shouldRefreshAnilistOnConfigReload( initialArgs: CliArgs | null | undefined, ): boolean { diff --git a/src/main/runtime/startup-osd-sequencer.test.ts b/src/main/runtime/startup-osd-sequencer.test.ts index 48d6da85..6fdbc39c 100644 --- a/src/main/runtime/startup-osd-sequencer.test.ts +++ b/src/main/runtime/startup-osd-sequencer.test.ts @@ -161,6 +161,34 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => { assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']); }); +test('startup OSD reset preserves in-flight tokenization loading for ready update', () => { + 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.reset(); + sequencer.markTokenizationReady(); + + 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', + ]); +}); + test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => { const osdMessages: string[] = []; const sequencer = createStartupOsdSequencer({ @@ -222,3 +250,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..4dd35f66 100644 --- a/src/main/runtime/startup-osd-sequencer.ts +++ b/src/main/runtime/startup-osd-sequencer.ts @@ -1,10 +1,30 @@ +import type { NotificationType, OverlayNotificationPayload } from '../../types/notification'; +import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing'; + 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; +} + +export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): { reset: () => void; + showTokenizationLoading: (message: string) => void; markTokenizationReady: () => void; showAnnotationLoading: (message: string) => void; markAnnotationLoadingComplete: (message: string) => void; @@ -12,6 +32,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 +41,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 +135,31 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => return { reset: () => { tokenizationReady = tokenizationWarmupCompleted; + if (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 +167,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) => showAnnotationLoading: (message) => { annotationLoadingMessage = message; if (tokenizationReady) { - showOsd(message); + notifyAnnotation(message, 'progress', true); } }, markAnnotationLoadingComplete: (message) => { @@ -84,7 +178,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..f0856283 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,22 +12,52 @@ 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', ]); }); +test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => { + const payloads: OverlayNotificationPayload[] = []; + + await notifyUpdateAvailable( + { notificationType: 'overlay', version: '0.15.0' }, + { + showSystemNotification: () => {}, + showOsdNotification: async () => {}, + showOverlayNotification: (nextPayload) => { + payloads.push(nextPayload); + }, + log: () => {}, + }, + ); + + const payload = payloads[0]; + assert.ok(payload); + assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]); + assert.equal(payload.id, 'subminer-update-available'); + assert.equal(payload.persistent, true); +}); + test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => { const calls: string[] = []; @@ -39,6 +70,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 +94,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..850f5c1f 100644 --- a/src/main/runtime/update/update-notifications.ts +++ b/src/main/runtime/update/update-notifications.ts @@ -1,7 +1,12 @@ import type { UpdateNotificationType } from '../../../types/config'; +import type { OverlayNotificationPayload } from '../../../types/notification'; + +export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available'; +export const INSTALL_UPDATE_ACTION_ID = 'install-update'; export interface UpdateNotificationDeps { showSystemNotification: (title: string, body: string) => void; + showOverlayNotification: (payload: OverlayNotificationPayload) => void; showOsdNotification: (message: string) => void | Promise; log: (message: string) => void; } @@ -13,10 +18,17 @@ 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({ + id: UPDATE_AVAILABLE_NOTIFICATION_ID, + title: 'SubMiner update available', + body: message, + variant: 'info', + persistent: true, + actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }], + }); } - if (options.notificationType === 'osd' || options.notificationType === 'both') { + if (options.notificationType === 'osd' || options.notificationType === 'osd-system') { try { await deps.showOsdNotification(message); } catch (error) { @@ -24,4 +36,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/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index da01f7a7..370a9308 100644 --- a/src/main/runtime/update/update-service.test.ts +++ b/src/main/runtime/update/update-service.test.ts @@ -96,6 +96,28 @@ test('manual update check falls back to GitHub release when app metadata is unav assert.deepEqual(calls, ['available-dialog:0.15.0']); }); +test('manual update install request skips available dialog and updates app', async () => { + const { deps, calls } = createDeps({ + checkAppUpdate: async () => ({ available: true, version: '0.15.0' }), + showUpdateAvailableDialog: async () => { + throw new Error('unexpected update confirmation'); + }, + updateLauncher: async (_launcherPath, channel) => { + calls.push(`launcher:${channel}`); + return { status: 'skipped' }; + }, + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ + source: 'manual', + installWhenAvailable: true, + }); + + assert.equal(result.status, 'updated'); + assert.deepEqual(calls, ['download', 'launcher:stable', 'restart-dialog']); +}); + test('manual update check reports available when no update asset was applied', async () => { const { deps, calls } = createDeps({ checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }), @@ -271,6 +293,28 @@ test('concurrent update checks share one in-flight check', async () => { assert.equal(checkCount, 1); }); +test('manual install request does not reuse in-flight manual check', async () => { + let checkCount = 0; + const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = []; + const { deps } = createDeps({ + checkAppUpdate: () => + new Promise((resolve) => { + checkCount += 1; + resolveChecks.push(resolve); + }), + }); + const service = createUpdateService(deps); + const manualCheck = service.checkForUpdates({ source: 'manual' }); + const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true }); + + await Promise.resolve(); + assert.equal(checkCount, 2); + for (const resolve of resolveChecks) { + resolve({ available: false, version: '0.14.0' }); + } + await Promise.all([manualCheck, manualInstall]); +}); + test('manual update check does not reuse in-flight automatic check', async () => { let checkCount = 0; const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = []; diff --git a/src/main/runtime/update/update-service.ts b/src/main/runtime/update/update-service.ts index f6382fb9..c9d0c24f 100644 --- a/src/main/runtime/update/update-service.ts +++ b/src/main/runtime/update/update-service.ts @@ -15,6 +15,7 @@ export interface UpdateCheckRequest { source: UpdateCheckSource; force?: boolean; launcherPath?: string; + installWhenAvailable?: boolean; } export type UpdateCheckStatus = @@ -107,7 +108,14 @@ function summarizeError(error: unknown): string { } export function createUpdateService(deps: UpdateServiceDeps) { - const inFlightBySource = new Map>(); + const inFlightBySource = new Map>(); + + function getInFlightKey(request: UpdateCheckRequest): string { + if (request.source === 'manual') { + return request.installWhenAvailable ? 'manual:install' : 'manual:check'; + } + return request.source; + } async function runCheck(request: UpdateCheckRequest): Promise { const now = deps.now(); @@ -164,9 +172,11 @@ export function createUpdateService(deps: UpdateServiceDeps) { return { status: 'update-available', version: latest.version }; } - const choice = await deps.showUpdateAvailableDialog(latest.version); - if (choice === 'close') { - return { status: 'update-available', version: latest.version }; + if (!request.installWhenAvailable) { + const choice = await deps.showUpdateAvailableDialog(latest.version); + if (choice === 'close') { + return { status: 'update-available', version: latest.version }; + } } const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false; @@ -203,12 +213,13 @@ export function createUpdateService(deps: UpdateServiceDeps) { return { checkForUpdates(request: UpdateCheckRequest): Promise { - const inFlight = inFlightBySource.get(request.source); + const key = getInFlightKey(request); + const inFlight = inFlightBySource.get(key); if (inFlight) return inFlight; const nextInFlight = runCheck(request).finally(() => { - inFlightBySource.delete(request.source); + inFlightBySource.delete(key); }); - inFlightBySource.set(request.source, nextInFlight); + inFlightBySource.set(key, nextInFlight); return nextInFlight; }, startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void { diff --git a/src/main/runtime/visible-overlay-autoplay-readiness.test.ts b/src/main/runtime/visible-overlay-autoplay-readiness.test.ts index 80269df0..c301c651 100644 --- a/src/main/runtime/visible-overlay-autoplay-readiness.test.ts +++ b/src/main/runtime/visible-overlay-autoplay-readiness.test.ts @@ -62,7 +62,41 @@ test('visible overlay autoplay target falls back when interactive rects have no assert.equal(ready, true); }); -test('visible overlay autoplay target rejects synthetic warmup readiness', () => { +test('visible overlay autoplay target accepts synthetic warmup readiness after content-ready', () => { + const ready = isVisibleOverlayAutoplayTargetReady( + { + getVisibleOverlayVisible: () => true, + isOverlayWindowReady: () => true, + getLatestVisibleMeasurement: () => null, + }, + { + mediaPath: '/media/video.mkv', + payload: { text: '__warm__', tokens: null }, + requestedAtMs: 1_000, + }, + ); + + assert.equal(ready, true); +}); + +test('visible overlay autoplay target waits for content-ready before synthetic warmup readiness', () => { + const ready = isVisibleOverlayAutoplayTargetReady( + { + getVisibleOverlayVisible: () => true, + isOverlayWindowReady: () => false, + getLatestVisibleMeasurement: () => visibleMeasurement(2_000), + }, + { + mediaPath: '/media/video.mkv', + payload: { text: '__warm__', tokens: null }, + requestedAtMs: 1_000, + }, + ); + + assert.equal(ready, false); +}); + +test('visible overlay autoplay target rejects empty readiness payloads', () => { const ready = isVisibleOverlayAutoplayTargetReady( { getVisibleOverlayVisible: () => true, @@ -71,7 +105,7 @@ test('visible overlay autoplay target rejects synthetic warmup readiness', () => }, { mediaPath: '/media/video.mkv', - payload: { text: '__warm__', tokens: null }, + payload: { text: '', tokens: null }, requestedAtMs: 1_000, }, ); diff --git a/src/main/runtime/visible-overlay-autoplay-readiness.ts b/src/main/runtime/visible-overlay-autoplay-readiness.ts index 4ee4ac66..fc5eb7ca 100644 --- a/src/main/runtime/visible-overlay-autoplay-readiness.ts +++ b/src/main/runtime/visible-overlay-autoplay-readiness.ts @@ -31,7 +31,7 @@ export function isVisibleOverlayAutoplayTargetReady( } const subtitleText = signal.payload.text.trim(); - if (!subtitleText || subtitleText === '__warm__') { + if (!subtitleText) { return false; } @@ -39,6 +39,10 @@ export function isVisibleOverlayAutoplayTargetReady( return false; } + if (subtitleText === '__warm__') { + return true; + } + const measurement = deps.getLatestVisibleMeasurement(); if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) { return false; 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..0d1d551f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -59,6 +59,8 @@ import type { YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, + OverlayNotificationEventPayload, + OverlayNotificationPosition, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -206,6 +208,14 @@ const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload const onOverlayPointerRecoveryRequestEvent = createQueuedIpcListener( IPC_CHANNELS.event.overlayPointerRecoveryRequest, ); +const onOverlayNotificationEvent = + createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.overlayNotification, + (payload) => payload as OverlayNotificationEventPayload, + ); +const onNotificationHistoryToggleEvent = createQueuedIpcListener( + IPC_CHANNELS.event.notificationHistoryToggle, +); const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload( IPC_CHANNELS.event.subtitleVisibility, (payload) => payload === true, @@ -229,6 +239,19 @@ const electronAPI: ElectronAPI = { onSubtitleSetEvent(callback); }, onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent, + onOverlayNotification: onOverlayNotificationEvent, + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { + notificationId, + actionId, + ...(options?.noteId !== undefined ? { noteId: options.noteId } : {}), + }); + }, + onNotificationHistoryToggle: onNotificationHistoryToggleEvent, onVisibility: (callback: (visible: boolean) => void) => { onSubtitleVisibilityEvent(callback); @@ -302,6 +325,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }), getStatsToggleKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), + getOverlayNotificationPosition: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayNotificationPosition), getMarkWatchedKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getMarkWatchedKey), markActiveVideoWatched: (): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 507796ca..8a854a01 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -94,6 +94,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts { openControllerSelect: null, openControllerDebug: null, toggleSubtitleSidebar: null, + toggleNotificationHistory: null, }; } @@ -133,6 +134,7 @@ function installKeyboardTestGlobals() { openControllerSelect: 'Alt+C', openControllerDebug: 'Alt+Shift+C', toggleSubtitleSidebar: '', + toggleNotificationHistory: '', toggleVisibleOverlayGlobal: '', }; let markActiveVideoWatchedResult = true; @@ -1178,6 +1180,7 @@ test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', a openControllerSelect: 'Alt+C', openControllerDebug: 'Alt+Shift+C', toggleSubtitleSidebar: '', + toggleNotificationHistory: '', toggleVisibleOverlayGlobal: '', }); testGlobals.setStatsToggleKey(''); diff --git a/src/renderer/index.html b/src/renderer/index.html index 5b990df4..4ea76660 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,6 +42,37 @@ role="status" aria-live="polite" > + +
diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 38abede0..9d8728bb 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -201,6 +201,8 @@ function describeSessionAction( return 'Toggle secondary subtitle mode'; case 'toggleSubtitleSidebar': return 'Toggle subtitle sidebar'; + case 'toggleNotificationHistory': + return 'Toggle notification history'; case 'markAudioCard': return 'Mark audio card'; case 'markWatched': @@ -254,6 +256,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string { case 'toggleVisibleOverlay': case 'toggleSecondarySub': case 'toggleSubtitleSidebar': + case 'toggleNotificationHistory': return 'Overlay controls'; case 'triggerSubsync': return 'Subtitle sync'; 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..ffd5f4aa 100644 --- a/src/renderer/overlay-content-measurement.ts +++ b/src/renderer/overlay-content-measurement.ts @@ -76,6 +76,22 @@ 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); + } + } + + if (ctx.state?.notificationHistoryOpen) { + const historyRect = toMeasuredRect(ctx.dom.overlayNotificationHistory.getBoundingClientRect()); + if (historyRect && hasArea(historyRect)) { + rects.push(historyRect); + } + } + return rects; } diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 13ad1bf7..f06584ba 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -29,7 +29,11 @@ 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 || + ctx.state.isOverNotificationHistory || + shouldKeepWindowInteractive; const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform ? shouldKeepWindowInteractive : shouldStayInteractive; diff --git a/src/renderer/overlay-notification-history.test.ts b/src/renderer/overlay-notification-history.test.ts new file mode 100644 index 00000000..167a843b --- /dev/null +++ b/src/renderer/overlay-notification-history.test.ts @@ -0,0 +1,415 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { OverlayNotificationEntry } from './overlay-notifications'; +import { + createOverlayNotificationHistoryPanel, + createOverlayNotificationHistoryStore, + resolveHistorySideFromStack, +} from './overlay-notification-history'; + +function entry( + overrides: Partial & { id: string }, +): OverlayNotificationEntry { + return { + title: overrides.title ?? overrides.id, + persistent: false, + createdAt: 0, + ...overrides, + }; +} + +test('history store lists newest entries first', () => { + const store = createOverlayNotificationHistoryStore(); + store.record(entry({ id: 'a', title: 'A' })); + store.record(entry({ id: 'b', title: 'B' })); + store.record(entry({ id: 'c', title: 'C' })); + + assert.deepEqual( + store.list().map((item) => item.id), + ['c', 'b', 'a'], + ); + assert.equal(store.size(), 3); +}); + +test('history store updates an entry in place without reordering or duplicating', () => { + let clock = 100; + const store = createOverlayNotificationHistoryStore({ now: () => clock }); + store.record(entry({ id: 'job', title: 'Working', body: 'Step 1', variant: 'progress' })); + store.record(entry({ id: 'other', title: 'Other' })); + clock = 200; + store.record(entry({ id: 'job', title: 'Done', body: 'Step 2', variant: 'success' })); + + const list = store.list(); + assert.equal(store.size(), 2); + // Newest-first ordering is by first-seen; the in-place update keeps 'other' on top. + assert.deepEqual( + list.map((item) => item.id), + ['other', 'job'], + ); + const job = list.find((item) => item.id === 'job'); + assert.equal(job?.title, 'Done'); + assert.equal(job?.body, 'Step 2'); + assert.equal(job?.variant, 'success'); + assert.equal(job?.createdAt, 100); + assert.equal(job?.updatedAt, 200); +}); + +test('history store keeps same live notification id when history ids differ', () => { + const store = createOverlayNotificationHistoryStore(); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Checking character dictionary...', + variant: 'progress', + historyId: 'character-dictionary-auto-sync-checking', + }), + ); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Building character dictionary...', + variant: 'progress', + historyId: 'character-dictionary-auto-sync-building', + }), + ); + store.record( + entry({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Character dictionary ready', + variant: 'success', + historyId: 'character-dictionary-auto-sync-ready', + }), + ); + + assert.deepEqual( + store.list().map((item) => `${item.id}:${item.body}`), + [ + 'character-dictionary-auto-sync-ready:Character dictionary ready', + 'character-dictionary-auto-sync-building:Building character dictionary...', + 'character-dictionary-auto-sync-checking:Checking character dictionary...', + ], + ); +}); + +test('history store removes and clears entries', () => { + const store = createOverlayNotificationHistoryStore(); + store.record(entry({ id: 'a' })); + store.record(entry({ id: 'b' })); + + store.remove('a'); + assert.deepEqual( + store.list().map((item) => item.id), + ['b'], + ); + + store.clear(); + assert.equal(store.size(), 0); + assert.deepEqual(store.list(), []); +}); + +test('history store caps to max and drops the oldest entries', () => { + const store = createOverlayNotificationHistoryStore({ max: 2 }); + store.record(entry({ id: 'a' })); + store.record(entry({ id: 'b' })); + store.record(entry({ id: 'c' })); + + assert.equal(store.size(), 2); + assert.deepEqual( + store.list().map((item) => item.id), + ['c', 'b'], + ); +}); + +test('history store defaults missing variant to info', () => { + const store = createOverlayNotificationHistoryStore(); + store.record(entry({ id: 'a' })); + assert.equal(store.list()[0]?.variant, 'info'); +}); + +test('history store preserves notification actions', () => { + const store = createOverlayNotificationHistoryStore(); + store.record( + entry({ + id: 'anki-update-progress', + title: 'Anki Card Updated', + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }), + ); + + assert.deepEqual(store.list()[0]?.actions, [ + { id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }, + ]); +}); + +test('panel side mirrors the notification stack position', () => { + const stackWith = (positionClass: string) => + ({ classList: { contains: (token: string) => token === positionClass } }) as unknown as Element; + + assert.equal(resolveHistorySideFromStack(stackWith('position-top-left')), 'left'); + assert.equal(resolveHistorySideFromStack(stackWith('position-top-right')), 'right'); + // Center notifications open the panel from the right. + assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right'); +}); + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +type FakeElement = { + tagName: string; + className: string; + textContent: string; + type: string; + dataset: Record; + children: FakeElement[]; + classList: ReturnType; + append: (...children: FakeElement[]) => void; + replaceChildren: (...children: FakeElement[]) => void; + setAttribute: (name: string, value: string) => void; + addEventListener: (type: string, listener: () => void) => void; + dispatchEventType: (type: string) => void; +}; + +function createFakeElement(tagName = 'div'): FakeElement { + const listeners = new Map void>>(); + const element: FakeElement = { + tagName: tagName.toUpperCase(), + className: '', + textContent: '', + type: '', + dataset: {}, + children: [], + classList: createClassList(), + append: (...children) => { + element.children.push(...children); + }, + replaceChildren: (...children) => { + element.children = [...children]; + }, + setAttribute: () => undefined, + addEventListener: (type, listener) => { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + dispatchEventType: (type) => { + for (const listener of listeners.get(type) ?? []) listener(); + }, + }; + return element; +} + +function findChildByClass(element: FakeElement, className: string): FakeElement | null { + if (element.className.split(/\s+/).includes(className)) { + return element; + } + for (const child of element.children) { + const match = findChildByClass(child, className); + if (match) return match; + } + return null; +} + +function createPanelHarness(stackPositionClass: string) { + const stack = { + classList: createClassList([stackPositionClass]), + }; + const clearButton = { + disabled: false, + addEventListener: () => undefined, + }; + const closeButton = { + addEventListener: () => undefined, + }; + const list = { + replaceChildren: () => undefined, + }; + const empty = { + classList: createClassList(), + }; + const panel = { + classList: createClassList(['notification-history', 'side-right']), + setAttribute: () => undefined, + addEventListener: () => undefined, + querySelector: (selector: string) => { + switch (selector) { + case '.notification-history-list': + return list; + case '.notification-history-empty': + return empty; + case '.notification-history-clear': + return clearButton; + case '.notification-history-close': + return closeButton; + default: + return null; + } + }, + }; + + const controller = createOverlayNotificationHistoryPanel({ + dom: { + overlay: createFakeElement(), + overlayNotificationHistory: panel, + overlayNotificationStack: stack, + }, + state: { + isOverNotificationHistory: false, + notificationHistoryOpen: false, + }, + platform: { + shouldToggleMouseIgnore: false, + }, + } as never); + + return { controller, panel, stack }; +} + +test('history panel applies the initial stack side while still closed', () => { + const { panel } = createPanelHarness('position-top-left'); + + assert.equal(panel.classList.contains('side-left'), true); + assert.equal(panel.classList.contains('side-right'), false); + assert.equal(panel.classList.contains('open'), false); +}); + +test('history panel resyncs the closed side before first open', () => { + const { controller, panel, stack } = createPanelHarness('position-top-right'); + + stack.classList.remove('position-top-right'); + stack.classList.add('position-top-left'); + + const syncable = controller as unknown as { syncSide?: () => void }; + assert.equal(typeof syncable.syncSide, 'function'); + syncable.syncSide?.(); + + assert.equal(panel.classList.contains('side-left'), true); + assert.equal(panel.classList.contains('side-right'), false); + assert.equal(panel.classList.contains('open'), false); +}); + +test('history panel action buttons send action ids and note ids', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const renderedItems: FakeElement[] = []; + const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; + const stack = { + classList: createClassList(['position-top-right']), + }; + const clearButton = createFakeElement('button'); + const closeButton = createFakeElement('button'); + const list = { + replaceChildren: (...children: FakeElement[]) => { + renderedItems.splice(0, renderedItems.length, ...children); + }, + }; + const empty = createFakeElement(); + const panel = { + classList: createClassList(['notification-history', 'side-right']), + setAttribute: () => undefined, + addEventListener: () => undefined, + querySelector: (selector: string) => { + switch (selector) { + case '.notification-history-list': + return list; + case '.notification-history-empty': + return empty; + case '.notification-history-clear': + return clearButton; + case '.notification-history-close': + return closeButton; + default: + return null; + } + }, + }; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + electronAPI: { + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + sentActions.push({ notificationId, actionId, noteId: options?.noteId }); + }, + }, + }, + }); + + try { + const controller = createOverlayNotificationHistoryPanel({ + dom: { + overlay: createFakeElement(), + overlayNotificationHistory: panel, + overlayNotificationStack: stack, + }, + state: { + isOverNotificationHistory: false, + notificationHistoryOpen: false, + }, + platform: { + shouldToggleMouseIgnore: false, + }, + } as never); + + controller.record( + entry({ + id: 'anki-update-progress', + title: 'Anki Card Updated', + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }), + ); + controller.open(); + + const button = renderedItems[0] + ? findChildByClass(renderedItems[0], 'notification-history-action') + : null; + if (!button) { + assert.fail('Expected notification history action button.'); + } + button.dispatchEventType('click'); + + assert.deepEqual(sentActions, [ + { notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 }, + ]); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); diff --git a/src/renderer/overlay-notification-history.ts b/src/renderer/overlay-notification-history.ts new file mode 100644 index 00000000..b796de0b --- /dev/null +++ b/src/renderer/overlay-notification-history.ts @@ -0,0 +1,265 @@ +import type { OverlayNotificationAction, OverlayNotificationVariant } from '../types'; +import type { RendererContext } from './context'; +import type { OverlayNotificationEntry } from './overlay-notifications.js'; +import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js'; + +export const DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX = 200; + +const OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES = [ + 'info', + 'progress', + 'success', + 'warning', + 'error', +] as const; + +export type OverlayNotificationHistoryEntry = { + id: string; + title: string; + body?: string; + image?: string; + variant: OverlayNotificationVariant; + actions?: OverlayNotificationAction[]; + createdAt: number; + updatedAt: number; +}; + +export type OverlayNotificationHistoryStoreOptions = { + max?: number; + now?: () => number; +}; + +function normalizeVariant( + variant: OverlayNotificationVariant | undefined, +): OverlayNotificationVariant { + return variant ?? 'info'; +} + +/** + * Session-scoped log of every overlay notification that was shown. Entries are keyed by historyId + * when provided, otherwise by live notification id. Reusing a key updates the record in place; + * distinct history keys preserve separate visible events. Ordering is by first-seen so the panel can + * render newest-first. + */ +export function createOverlayNotificationHistoryStore( + options: OverlayNotificationHistoryStoreOptions = {}, +) { + const max = Math.max(1, options.max ?? DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX); + const now = options.now ?? (() => Date.now()); + const entries = new Map(); + + function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry { + const timestamp = now(); + const historyId = entry.historyId?.trim() || entry.id; + const existing = entries.get(historyId); + const next: OverlayNotificationHistoryEntry = { + id: historyId, + title: entry.title, + body: entry.body, + image: entry.image, + variant: normalizeVariant(entry.variant), + actions: entry.actions?.map((action) => ({ ...action })), + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + // Setting an existing key keeps its original insertion slot, so an in-place update (same id, + // new body) refreshes content without jumping the entry to the top of the panel. + entries.set(historyId, next); + while (entries.size > max) { + const oldest = entries.keys().next().value; + if (oldest === undefined) break; + entries.delete(oldest); + } + return next; + } + + function remove(id: string): void { + entries.delete(id); + } + + function clear(): void { + entries.clear(); + } + + function list(): OverlayNotificationHistoryEntry[] { + // Newest first. + return [...entries.values()].reverse(); + } + + function size(): number { + return entries.size; + } + + return { record, remove, clear, list, size }; +} + +export type OverlayNotificationHistorySide = 'left' | 'right'; + +/** + * The history panel slides in from the same edge the notifications use: left when notifications are + * top-left, right otherwise (including center). We read the live position class off the notification + * stack so the panel always tracks the configured/last-used position. + */ +export function resolveHistorySideFromStack(stack: Element): OverlayNotificationHistorySide { + return stack.classList.contains('position-top-left') ? 'left' : 'right'; +} + +export function createOverlayNotificationHistoryPanel( + ctx: RendererContext, + options: { onChanged?: () => void } = {}, +) { + const store = createOverlayNotificationHistoryStore(); + const panel = ctx.dom.overlayNotificationHistory; + const list = panel.querySelector('.notification-history-list'); + const empty = panel.querySelector('.notification-history-empty'); + const clearButton = panel.querySelector('.notification-history-clear'); + const closeButton = panel.querySelector('.notification-history-close'); + let open = false; + + function setInteractive(value: boolean): void { + ctx.state.isOverNotificationHistory = value; + syncOverlayMouseIgnoreState(ctx); + } + + function applySide(): void { + const side = resolveHistorySideFromStack(ctx.dom.overlayNotificationStack); + panel.classList.toggle('side-left', side === 'left'); + panel.classList.toggle('side-right', side === 'right'); + } + + function formatTime(timestamp: number): string { + try { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return ''; + } + } + + function buildItem(entry: OverlayNotificationHistoryEntry): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'notification-history-item'; + for (const variant of OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES) { + item.classList.toggle(variant, variant === entry.variant); + } + item.dataset.notificationId = entry.id; + + const trimmedImage = entry.image?.trim(); + const leading = trimmedImage ? document.createElement('img') : document.createElement('span'); + leading.className = trimmedImage ? 'notification-history-thumb' : 'notification-history-icon'; + leading.setAttribute('aria-hidden', 'true'); + if (trimmedImage) { + const image = leading as HTMLImageElement; + image.src = trimmedImage; + image.alt = ''; + image.decoding = 'async'; + } + + const content = document.createElement('div'); + content.className = 'notification-history-content'; + + const title = document.createElement('div'); + title.className = 'notification-history-item-title'; + title.textContent = entry.title; + content.append(title); + + if (entry.body && entry.body.trim().length > 0) { + const body = document.createElement('div'); + body.className = 'notification-history-item-body'; + body.textContent = entry.body; + content.append(body); + } + + const time = document.createElement('time'); + time.className = 'notification-history-time'; + time.dateTime = new Date(entry.createdAt).toISOString(); + time.textContent = formatTime(entry.createdAt); + content.append(time); + + if (entry.actions && entry.actions.length > 0) { + const actions = document.createElement('div'); + actions.className = 'notification-history-actions'; + for (const action of entry.actions) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'notification-history-action'; + button.textContent = action.label; + button.addEventListener('click', () => { + window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, { + noteId: action.noteId, + }); + }); + actions.append(button); + } + content.append(actions); + } + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'notification-history-remove'; + remove.setAttribute('aria-label', 'Remove from history'); + remove.textContent = '×'; + remove.addEventListener('click', () => { + store.remove(entry.id); + render(); + }); + + item.append(leading, content, remove); + return item; + } + + function render(): void { + if (!list || !empty) return; + const entries = store.list(); + list.replaceChildren(...entries.map(buildItem)); + empty.classList.toggle('hidden', entries.length > 0); + if (clearButton) clearButton.disabled = entries.length === 0; + options.onChanged?.(); + } + + function setOpen(next: boolean): void { + if (open === next) return; + open = next; + ctx.state.notificationHistoryOpen = next; + if (next) { + applySide(); + render(); + } + panel.classList.toggle('open', next); + panel.setAttribute('aria-hidden', next ? 'false' : 'true'); + setInteractive(next); + options.onChanged?.(); + } + + clearButton?.addEventListener('click', () => { + store.clear(); + render(); + }); + closeButton?.addEventListener('click', () => setOpen(false)); + panel.addEventListener('mouseenter', () => { + if (open) setInteractive(true); + }); + panel.addEventListener('mouseleave', () => setInteractive(false)); + applySide(); + + function record(entry: OverlayNotificationEntry): void { + store.record(entry); + if (open) render(); + } + + function toggle(): void { + setOpen(!open); + } + + return { + record, + toggle, + open: () => setOpen(true), + close: () => setOpen(false), + isOpen: () => open, + syncSide: applySide, + }; +} diff --git a/src/renderer/overlay-notifications-hover.test.ts b/src/renderer/overlay-notifications-hover.test.ts new file mode 100644 index 00000000..05b192a0 --- /dev/null +++ b/src/renderer/overlay-notifications-hover.test.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createOverlayNotificationRenderer } from './overlay-notifications'; + +function createClassList() { + const tokens = new Set(); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +type FakeElement = { + tagName: string; + className: string; + textContent: string; + type: string; + dataset: Record; + children: FakeElement[]; + classList: ReturnType; + append: (...children: FakeElement[]) => void; + replaceChildren: (...children: FakeElement[]) => void; + remove: () => void; + setAttribute: (name: string, value: string) => void; + addEventListener: (type: string, listener: (event?: unknown) => void) => void; + dispatchEventType: (type: string, event?: unknown) => void; +}; + +function createFakeElement(tagName = 'div'): FakeElement { + const listeners = new Map void>>(); + const element: FakeElement = { + tagName: tagName.toUpperCase(), + className: '', + textContent: '', + type: '', + dataset: {}, + children: [], + classList: createClassList(), + append: (...children) => { + for (const child of children) { + const existingIndex = element.children.indexOf(child); + if (existingIndex >= 0) { + element.children.splice(existingIndex, 1); + } + element.children.push(child); + } + }, + replaceChildren: (...children) => { + element.children = [...children]; + }, + setAttribute: () => undefined, + remove: () => undefined, + addEventListener: (type, listener) => { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + dispatchEventType: (type, event) => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + }; + return element; +} + +function findChildByClass(element: FakeElement, className: string): FakeElement | null { + if (element.className.split(/\s+/).includes(className)) { + return element; + } + for (const child of element.children) { + const match = findChildByClass(child, className); + if (match) return match; + } + return null; +} + +function createHoverContext(stack: FakeElement, ignoreCalls: Array<{ ignore: boolean }>) { + return { + dom: { + overlay: { classList: createClassList() }, + overlayNotificationStack: stack, + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + isOverOverlayNotification: false, + isOverNotificationHistory: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + }, + }; +} + +function installDomGlobals(ignoreCalls: Array<{ ignore: boolean }>): () => void { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + querySelectorAll: () => [], + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + clearTimeout: () => undefined, + setTimeout: () => 1, + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean) => { + ignoreCalls.push({ ignore }); + }, + }, + }, + }); + + return () => { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + }; +} + +test('passive overlay notification hover stays click-through on macOS passthrough overlays', () => { + const stack = createFakeElement(); + const ignoreCalls: Array<{ ignore: boolean }> = []; + const ctx = createHoverContext(stack, ignoreCalls); + const restore = installDomGlobals(ignoreCalls); + + try { + const renderer = createOverlayNotificationRenderer(ctx as never); + renderer.show({ + id: 'character-dictionary-auto-sync', + title: 'Character dictionary', + body: 'Building character dictionary...', + variant: 'progress', + persistent: true, + }); + + stack.dispatchEventType('mouseenter'); + + assert.equal(ctx.state.isOverOverlayNotification, false); + assert.deepEqual(ignoreCalls, []); + + const card = stack.children[0]; + const close = card ? findChildByClass(card, 'overlay-notification-close') : null; + if (!close) { + assert.fail('Expected overlay notification close button.'); + } + + close.dispatchEventType('mouseenter'); + assert.equal(ctx.state.isOverOverlayNotification, false); + assert.deepEqual(ignoreCalls, []); + } finally { + restore(); + } +}); + +test('overlay notification controls become interactive on hover', () => { + const stack = createFakeElement(); + const ignoreCalls: Array<{ ignore: boolean }> = []; + const ctx = createHoverContext(stack, ignoreCalls); + const restore = installDomGlobals(ignoreCalls); + + try { + const renderer = createOverlayNotificationRenderer(ctx as never); + renderer.show({ + id: 'mined-card', + title: 'Card created', + body: 'Added sentence card', + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + persistent: true, + }); + + const card = stack.children[0]; + const action = card ? findChildByClass(card, 'overlay-notification-action') : null; + if (!action) { + assert.fail('Expected overlay notification action.'); + } + + action.dispatchEventType('mouseenter'); + assert.equal(ctx.state.isOverOverlayNotification, true); + assert.deepEqual(ignoreCalls, [{ ignore: false }]); + + action.dispatchEventType('mouseleave'); + assert.equal(ctx.state.isOverOverlayNotification, false); + assert.deepEqual(ignoreCalls, [{ ignore: false }, { ignore: true }]); + } finally { + restore(); + } +}); + +test('action overlay notification stack hover keeps card controls interactive', () => { + const stack = createFakeElement(); + const ignoreCalls: Array<{ ignore: boolean }> = []; + const ctx = createHoverContext(stack, ignoreCalls); + const restore = installDomGlobals(ignoreCalls); + + try { + const renderer = createOverlayNotificationRenderer(ctx as never); + renderer.show({ + id: 'anki-card-updated', + title: 'Anki Card Updated', + body: 'Updated card: 食べる', + persistent: true, + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }); + + stack.dispatchEventType('mouseenter'); + + assert.equal(ctx.state.isOverOverlayNotification, true); + assert.deepEqual(ignoreCalls, [{ ignore: false }]); + } finally { + restore(); + } +}); diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts new file mode 100644 index 00000000..c2ac00ee --- /dev/null +++ b/src/renderer/overlay-notifications.test.ts @@ -0,0 +1,415 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createOverlayNotificationRenderer, + createOverlayNotificationStore, + handleOverlayNotificationEvent, + overlayNotificationPositionClass, +} from './overlay-notifications'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + contains: (entry: string) => tokens.has(entry), + toggle: (entry: string, force?: boolean) => { + if (force === true) tokens.add(entry); + else if (force === false) tokens.delete(entry); + else if (tokens.has(entry)) tokens.delete(entry); + else tokens.add(entry); + }, + }; +} + +type FakeElement = { + tagName: string; + className: string; + textContent: string; + src: string; + alt: string; + type: string; + dataset: Record; + children: FakeElement[]; + classList: ReturnType; + appendCalls: number; + replaceChildrenCalls: number; + append: (...children: FakeElement[]) => void; + replaceChildren: (...children: FakeElement[]) => void; + remove: () => void; + setAttribute: (name: string, value: string) => void; + getAttribute: (name: string) => string | null; + addEventListener: (type: string, listener: (event?: unknown) => void) => void; + dispatchEventType: (type: string, event?: unknown) => void; +}; + +function createFakeElement(tagName = 'div'): FakeElement { + const attributes = new Map(); + const listeners = new Map void>>(); + const element: FakeElement = { + tagName: tagName.toUpperCase(), + className: '', + textContent: '', + src: '', + alt: '', + type: '', + dataset: {}, + children: [], + classList: createClassList(), + appendCalls: 0, + replaceChildrenCalls: 0, + append: (...children) => { + element.appendCalls += 1; + for (const child of children) { + const existingIndex = element.children.indexOf(child); + if (existingIndex >= 0) { + element.children.splice(existingIndex, 1); + } + element.children.push(child); + } + }, + replaceChildren: (...children) => { + element.replaceChildrenCalls += 1; + element.children = [...children]; + }, + setAttribute: (name, value) => { + attributes.set(name, value); + }, + getAttribute: (name) => attributes.get(name) ?? null, + remove: () => undefined, + addEventListener: (type, listener) => { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + dispatchEventType: (type, event) => { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + }; + return element; +} + +function findChildByClass(element: FakeElement, className: string): FakeElement | null { + if (element.className.split(/\s+/).includes(className)) { + return element; + } + for (const child of element.children) { + const match = findChildByClass(child, className); + if (match) return match; + } + return null; +} + +const overlayNotificationCss = readFileSync( + path.join(__dirname, '..', 'renderer', 'style.css'), + 'utf8', +); + +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']); +}); + +test('overlay notification renderer shows thumbnail image from payload', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const stack = createFakeElement(); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + + try { + const renderer = createOverlayNotificationRenderer({ + dom: { + overlayNotificationStack: stack, + }, + state: { + isOverOverlayNotification: false, + }, + } as never); + + renderer.show({ + title: 'Anki Card Updated', + body: 'Updated card: 食べる', + image: 'file:///tmp/subminer-notification-icon.png', + variant: 'success', + persistent: true, + }); + + const card = stack.children[0]; + if (!card) { + assert.fail('Expected overlay notification card.'); + } + const image = findChildByClass(card, 'overlay-notification-image'); + if (!image) { + assert.fail('Expected overlay notification image.'); + } + + assert.equal(image.tagName, 'IMG'); + assert.equal(image.src, 'file:///tmp/subminer-notification-icon.png'); + assert.equal(image.alt, ''); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + } +}); + +test('overlay notification action buttons send action ids', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const stack = createFakeElement(); + const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = []; + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + clearTimeout: () => undefined, + setTimeout: () => { + return 1; + }, + electronAPI: { + sendOverlayNotificationAction: ( + notificationId: string, + actionId: string, + options?: { noteId?: number }, + ) => { + sentActions.push({ notificationId, actionId, noteId: options?.noteId }); + }, + }, + }, + }); + + try { + const renderer = createOverlayNotificationRenderer({ + dom: { + overlayNotificationStack: stack, + }, + state: { + isOverOverlayNotification: false, + }, + } as never); + + renderer.show({ + id: 'subminer-update-available', + title: 'SubMiner update available', + body: 'SubMiner v0.15.0 is available', + persistent: true, + actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }], + }); + + const card = stack.children[0]; + if (!card) { + assert.fail('Expected overlay notification card.'); + } + const button = findChildByClass(card, 'overlay-notification-action'); + if (!button) { + assert.fail('Expected overlay notification action button.'); + } + + button.dispatchEventType('click'); + + assert.deepEqual(sentActions, [ + { notificationId: 'subminer-update-available', actionId: 'open-anki-card', noteId: 42 }, + ]); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); + +test('overlay notification renderer updates same-id progress without replacing the spinner', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const stack = createFakeElement(); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + clearTimeout: () => undefined, + setTimeout: () => { + return 1; + }, + }, + }); + + try { + const renderer = createOverlayNotificationRenderer({ + dom: { + overlayNotificationStack: stack, + }, + state: { + isOverOverlayNotification: false, + }, + } as never); + + renderer.show({ + id: 'subsync-status', + title: 'Subsync', + body: 'Subsync: syncing |', + variant: 'progress', + persistent: true, + }); + + const card = stack.children[0]; + if (!card) { + assert.fail('Expected overlay notification card.'); + } + assert.equal(stack.appendCalls, 1); + assert.equal(card.classList.contains('entering'), true); + const spinner = findChildByClass(card, 'overlay-notification-icon'); + if (!spinner) { + assert.fail('Expected overlay notification spinner.'); + } + const cardReplacements = card.replaceChildrenCalls; + + renderer.show({ + id: 'subsync-status', + title: 'Subsync', + body: 'Subsync: syncing /', + variant: 'progress', + persistent: true, + }); + + assert.equal(stack.children.length, 1); + assert.equal(stack.children[0], card); + assert.equal(stack.appendCalls, 1); + assert.equal(card.replaceChildrenCalls, cardReplacements); + assert.equal(findChildByClass(card, 'overlay-notification-icon'), spinner); + assert.equal( + findChildByClass(card, 'overlay-notification-body')?.textContent, + 'Subsync: syncing /', + ); + + card.dispatchEventType('animationend', { animationName: 'overlay-notification-enter-right' }); + assert.equal(card.classList.contains('entering'), false); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); + +test('overlay notification cards use larger display dimensions', () => { + assert.match( + overlayNotificationCss, + /\.overlay-notification-stack\s*\{[^}]*width:\s*min\(420px,\s*calc\(100vw - 32px\)\);/s, + ); + assert.match( + overlayNotificationCss, + /\.overlay-notification-stack\s*\{[^}]*z-index:\s*2147483647\s*!important;/s, + ); + assert.match(overlayNotificationCss, /\.overlay-notification-card\s*\{[^}]*min-height:\s*72px;/s); + assert.match( + overlayNotificationCss, + /\.overlay-notification-card\.has-image\s*\{[^}]*min-height:\s*88px;/s, + ); + // The has-image card reserves a real grid track for the thumbnail so it + // cannot overlap the text, and the image shrinks to fit within that track. + assert.match( + overlayNotificationCss, + /\.overlay-notification-card\.has-image\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*100px\)\s+minmax\(0,\s*1fr\)\s+22px;/s, + ); + assert.match( + overlayNotificationCss, + /\.overlay-notification-image\s*\{[^}]*max-width:\s*100px;/s, + ); + assert.match( + overlayNotificationCss, + /\.overlay-notification-image\s*\{[^}]*aspect-ratio:\s*100 \/ 56;/s, + ); +}); diff --git a/src/renderer/overlay-notifications.ts b/src/renderer/overlay-notifications.ts new file mode 100644 index 00000000..9d347e27 --- /dev/null +++ b/src/renderer/overlay-notifications.ts @@ -0,0 +1,434 @@ +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; +const OVERLAY_NOTIFICATION_VARIANT_CLASSES = [ + 'info', + 'progress', + 'success', + 'warning', + 'error', +] as const; +// Matches the `.leaving` animation duration in style.css; the fallback timer guards +// against `animationend` never firing (e.g. element detached or reduced-motion). +const OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS = 260; + +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 normalizeImageSource(image: string | undefined): string | null { + if (!image) return null; + const trimmed = image.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function setInteractiveState(ctx: RendererContext, value: boolean): void { + ctx.state.isOverOverlayNotification = value; + syncOverlayMouseIgnoreState(ctx); +} + +function hasElementClass(element: Element | undefined, className: string): boolean { + if (!element) return false; + const legacyClassName = (element as { className?: unknown }).className; + return ( + element.classList.contains(className) || + (typeof legacyClassName === 'string' && legacyClassName.split(/\s+/).includes(className)) + ); +} + +function isNotificationCardIcon(element: Element | undefined): boolean { + return hasElementClass(element, 'overlay-notification-icon'); +} + +function isNotificationCardContent(element: Element | undefined): element is HTMLElement { + return hasElementClass(element, 'overlay-notification-content'); +} + +function isNotificationCardCloseButton(element: Element | undefined): boolean { + return hasElementClass(element, 'overlay-notification-close'); +} + +function hasExplicitNotificationActions(entry: OverlayNotificationEntry): boolean { + return (entry.actions?.length ?? 0) > 0; +} + +export function createOverlayNotificationRenderer( + ctx: RendererContext, + options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {}, +) { + const store = createOverlayNotificationStore(); + const timers = new Map(); + // Live card elements keyed by notification id so re-renders reuse them: the enter + // animation only plays for freshly created cards instead of replaying on every render. + const cards = new Map(); + const leaving = new Set(); + 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 commitExit(id: string, card: HTMLElement): void { + if (!leaving.has(id)) return; + leaving.delete(id); + cards.delete(id); + card.remove(); + if (cards.size === 0) { + ctx.dom.overlayNotificationStack.classList.add('hidden'); + setInteractiveState(ctx, false); + } + options.onChanged?.(); + } + + function beginExit(id: string, card: HTMLElement): void { + if (leaving.has(id)) return; + leaving.add(id); + card.classList.remove('entering'); + card.classList.add('leaving'); + const finalize = () => { + window.clearTimeout(fallback); + commitExit(id, card); + }; + const fallback = window.setTimeout(finalize, OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS); + card.addEventListener( + 'animationend', + (event) => { + if ((event as AnimationEvent).animationName?.startsWith('overlay-notification-leave')) { + finalize(); + } + }, + { once: true }, + ); + } + + function markEnterComplete(card: HTMLElement): void { + card.classList.remove('entering'); + } + + function watchEnterAnimation(card: HTMLElement): void { + if (typeof window === 'undefined') { + return; + } + const fallback = window.setTimeout(() => markEnterComplete(card), 320); + card.addEventListener( + 'animationend', + (event) => { + if ((event as AnimationEvent).animationName?.startsWith('overlay-notification-enter')) { + window.clearTimeout(fallback); + markEnterComplete(card); + } + }, + { once: true }, + ); + } + + function appendCardIfNeeded(card: HTMLElement): void { + if (Array.prototype.includes.call(ctx.dom.overlayNotificationStack.children, card)) { + return; + } + ctx.dom.overlayNotificationStack.append(card); + } + + function bindInteractiveControlHover(element: HTMLElement): void { + element.addEventListener('mouseenter', () => setInteractiveState(ctx, true)); + element.addEventListener('mouseleave', () => setInteractiveState(ctx, false)); + } + + function remove(id: string): void { + clearTimer(id); + store.remove(id); + const card = cards.get(id); + if (card) { + beginExit(id, card); + } else { + render(); + } + } + + function populateContent(content: HTMLElement, entry: OverlayNotificationEntry): void { + content.className = 'overlay-notification-content'; + + const title = document.createElement('div'); + title.className = 'overlay-notification-title'; + title.textContent = entry.title; + const children: HTMLElement[] = [title]; + + if (entry.body && entry.body.trim().length > 0) { + const body = document.createElement('div'); + body.className = 'overlay-notification-body'; + body.textContent = entry.body; + children.push(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; + bindInteractiveControlHover(button); + button.addEventListener('click', () => { + window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, { + noteId: action.noteId, + }); + remove(entry.id); + }); + actions.append(button); + } + children.push(actions); + } + content.replaceChildren(...children); + } + + function createContent(entry: OverlayNotificationEntry): HTMLElement { + const content = document.createElement('div'); + populateContent(content, entry); + return content; + } + + function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void { + const imageSource = normalizeImageSource(entry.image); + card.classList.add('overlay-notification-card'); + for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) { + card.classList.toggle(variant, variant === normalizeVariant(entry.variant)); + } + card.classList.toggle('has-image', Boolean(imageSource)); + card.dataset.notificationId = entry.id; + card.setAttribute('role', 'status'); + + const leadingNode = card.children[0]; + const contentNode = card.children[1]; + const closeNode = card.children[2]; + if ( + leadingNode && + contentNode && + closeNode && + !imageSource && + !entry.actions?.length && + isNotificationCardIcon(leadingNode) && + isNotificationCardContent(contentNode) && + isNotificationCardCloseButton(closeNode) + ) { + populateContent(contentNode, entry); + return; + } + + const leadingEl = imageSource ? document.createElement('img') : document.createElement('span'); + leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon'; + leadingEl.setAttribute('aria-hidden', 'true'); + if (imageSource) { + const image = leadingEl as HTMLImageElement; + image.src = imageSource; + image.alt = ''; + image.decoding = 'async'; + } + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'overlay-notification-close'; + closeButton.setAttribute('aria-label', 'Dismiss notification'); + closeButton.textContent = '×'; + if (hasExplicitNotificationActions(entry)) { + bindInteractiveControlHover(closeButton); + } + closeButton.addEventListener('click', () => remove(entry.id)); + + card.replaceChildren(leadingEl, createContent(entry), closeButton); + } + + function render(): void { + const visible = store.visible(); + const visibleIds = new Set(visible.map((entry) => entry.id)); + const hasInteractiveCard = visible.some(hasExplicitNotificationActions); + ctx.dom.overlayNotificationStack.classList.toggle( + 'hidden', + visible.length === 0 && leaving.size === 0, + ); + ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES); + ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position)); + + // Cards that vanished from the store without an explicit remove() (e.g. pruned when + // over the visible cap) still need to animate out. + for (const [id, card] of cards) { + if (!visibleIds.has(id)) { + beginExit(id, card); + } + } + + for (const entry of visible) { + let card = cards.get(entry.id); + if (card && leaving.has(entry.id)) { + // The card was animating out but has been re-shown: cancel the exit. + leaving.delete(entry.id); + card.classList.remove('leaving'); + } + if (!card) { + card = document.createElement('section'); + card.classList.add('entering'); + watchEnterAnimation(card); + cards.set(entry.id, card); + } + populateCard(card, entry); + appendCardIfNeeded(card); + } + + if (visible.length === 0 && leaving.size === 0) { + setInteractiveState(ctx, false); + } else if (!hasInteractiveCard && ctx.state.isOverOverlayNotification) { + setInteractiveState(ctx, false); + } + options.onChanged?.(); + } + + ctx.dom.overlayNotificationStack.addEventListener('mouseenter', () => { + if (store.visible().some(hasExplicitNotificationActions)) { + 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; + options.onShow?.(entry); + 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-init-order.test.ts b/src/renderer/renderer-init-order.test.ts index 1ebe3b3e..38e7dbe8 100644 --- a/src/renderer/renderer-init-order.test.ts +++ b/src/renderer/renderer-init-order.test.ts @@ -58,6 +58,22 @@ test('renderer reports subtitle bounds immediately after initial subtitle layout assert.ok(immediateMeasurementIndex < listenerIndex); }); +test('renderer wires subtitle pointer handlers before first subtitle paint', () => { + const primaryMouseEnterIndex = indexOfRequired( + "ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);", + ); + const pointerTrackingIndex = indexOfRequired('mouseHandlers.setupPointerTracking();'); + const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);'); + const initialMeasurementIndex = indexOfRequired( + 'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();', + ); + + assert.ok(primaryMouseEnterIndex < initialRenderIndex); + assert.ok(pointerTrackingIndex < initialRenderIndex); + assert.ok(primaryMouseEnterIndex < initialMeasurementIndex); + assert.ok(pointerTrackingIndex < initialMeasurementIndex); +}); + test('renderer reports subtitle bounds immediately after live subtitle layout', () => { const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);'); const liveLayoutIndex = indexOfRequired( diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 090f1c47..0d88561c 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -45,6 +45,12 @@ 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, + overlayNotificationPositionClass, +} from './overlay-notifications.js'; +import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js'; import { createRendererState } from './state.js'; import { createSubtitleRenderer } from './subtitle-render.js'; import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; @@ -112,6 +118,16 @@ function syncSettingsModalSubtitleSuppression(): void { const subtitleRenderer = createSubtitleRenderer(ctx); const measurementReporter = createOverlayContentMeasurementReporter(ctx); +const notificationHistory = createOverlayNotificationHistoryPanel(ctx, { + onChanged: () => measurementReporter.schedule(), +}); +const overlayNotifications = createOverlayNotificationRenderer(ctx, { + onChanged: () => { + notificationHistory.syncSide(); + measurementReporter.schedule(); + }, + onShow: (entry) => notificationHistory.record(entry), +}); const positioning = createPositioningController(ctx); const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { modalStateReader: { isAnyModalOpen }, @@ -425,12 +441,30 @@ function restoreOverlayInteractionAfterError(): void { } } +const OVERLAY_TOAST_POSITION_CLASSES = [ + 'position-top-left', + 'position-top', + 'position-top-right', +] as const; + +// Mirror the notification stack's current position onto a toast so error/status toasts honor the +// configured `notifications.overlayPosition` instead of always pinning to the top-right corner. +function applyConfiguredToastPosition(toast: HTMLElement): void { + const stackClasses = ctx.dom.overlayNotificationStack.classList; + const active = + OVERLAY_TOAST_POSITION_CLASSES.find((cls) => stackClasses.contains(cls)) ?? + 'position-top-right'; + toast.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES); + toast.classList.add(active); +} + function showOverlayErrorToast(message: string): void { if (overlayErrorToastTimeout) { clearTimeout(overlayErrorToastTimeout); overlayErrorToastTimeout = null; } ctx.dom.overlayErrorToast.textContent = message; + applyConfiguredToastPosition(ctx.dom.overlayErrorToast); ctx.dom.overlayErrorToast.classList.remove('hidden'); overlayErrorToastTimeout = setTimeout(() => { ctx.dom.overlayErrorToast.classList.add('hidden'); @@ -601,6 +635,19 @@ async function init(): Promise { syncOverlayMouseIgnoreState(ctx); } + // Seed the notification stack position from config before subscribing to history toggles, so the + // closed history panel starts on the same side it will slide in from. + try { + const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition(); + ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES); + ctx.dom.overlayNotificationStack.classList.add( + overlayNotificationPositionClass(overlayNotificationPosition), + ); + notificationHistory.syncSide(); + } catch { + // Non-fatal: keep the default position class from index.html. + } + window.electronAPI.onOverlayPointerRecoveryRequested(() => { runGuarded('overlay:pointer-recovery', () => { if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) { @@ -612,6 +659,16 @@ async function init(): Promise { mouseHandlers.restorePointerInteractionState(); }); }); + window.electronAPI.onOverlayNotification((payload) => { + runGuarded('overlay:notification', () => { + handleOverlayNotificationEvent(overlayNotifications, payload); + }); + }); + window.electronAPI.onNotificationHistoryToggle(() => { + runGuarded('notification-history:toggle', () => { + notificationHistory.toggle(); + }); + }); await keyboardHandlers.setupMpvInputForwarding(); @@ -624,6 +681,22 @@ async function init(): Promise { ); measurementReporter.schedule(); + ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); + ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseenter', + mouseHandlers.handleSecondaryMouseEnter, + ); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseleave', + mouseHandlers.handleSecondaryMouseLeave, + ); + + mouseHandlers.setupResizeHandler(); + mouseHandlers.setupPointerTracking(); + mouseHandlers.setupSelectionObserver(); + mouseHandlers.setupYomitanObserver(); + window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { runGuarded('subtitle-position:update', () => { positioning.applyStoredSubtitlePosition(position, 'media-change'); @@ -678,21 +751,6 @@ async function init(): Promise { subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); measurementReporter.schedule(); - ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); - ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); - ctx.dom.secondarySubContainer.addEventListener( - 'mouseenter', - mouseHandlers.handleSecondaryMouseEnter, - ); - ctx.dom.secondarySubContainer.addEventListener( - 'mouseleave', - mouseHandlers.handleSecondaryMouseLeave, - ); - - mouseHandlers.setupResizeHandler(); - mouseHandlers.setupPointerTracking(); - mouseHandlers.setupSelectionObserver(); - mouseHandlers.setupYomitanObserver(); setupDragDropToMpvQueue(); window.addEventListener('resize', () => { measurementReporter.schedule(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index dc639b55..34bd575b 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -31,6 +31,9 @@ export type ChordAction = export type RendererState = { isOverSubtitle: boolean; isOverSubtitleSidebar: boolean; + isOverOverlayNotification: boolean; + isOverNotificationHistory: boolean; + notificationHistoryOpen: boolean; isDragging: boolean; dragStartY: number; startYPercent: number; @@ -143,6 +146,9 @@ export function createRendererState(): RendererState { return { isOverSubtitle: false, isOverSubtitleSidebar: false, + isOverOverlayNotification: false, + isOverNotificationHistory: false, + notificationHistoryOpen: false, isDragging: false, dragStartY: 0, startYPercent: 0, diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e7f402d..d9c3b480 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -146,6 +146,656 @@ body:focus-visible, transform: translateY(0); } +/* Follow the configured notification position (default stays top-right). */ +.overlay-error-toast.position-top-left { + left: 16px; + right: auto; +} + +.overlay-error-toast.position-top { + left: 50%; + right: auto; + transform: translate(-50%, -6px); +} + +.overlay-error-toast.position-top:not(.hidden) { + transform: translate(-50%, 0); +} + +.overlay-error-toast.position-top-right { + left: auto; + right: 16px; +} + +.overlay-notification-stack { + position: absolute; + top: 16px; + width: min(420px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: auto; + z-index: 2147483647 !important; +} + +.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 icon and border tint. */ + --overlay-notification-accent: var(--ctp-blue); + + position: relative; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 22px; + gap: 12px; + align-items: start; + min-height: 72px; + padding: 16px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 45%, var(--ctp-surface1)); + background: var(--ctp-base); + box-shadow: 0 12px 28px -12px rgba(24, 25, 38, 0.7); + color: var(--ctp-text); + overflow: hidden; +} + +/* Direction-aware enter/exit — slide in from the stack's anchored edge, slide back out. */ +.overlay-notification-card.entering { + animation: overlay-notification-enter-right 240ms cubic-bezier(0.21, 1.02, 0.73, 1) both; +} + +.overlay-notification-card.leaving { + pointer-events: none; + animation: overlay-notification-leave-right 190ms cubic-bezier(0.55, 0.06, 0.68, 0.19) both; +} + +.overlay-notification-stack.position-top-left .overlay-notification-card.entering { + animation-name: overlay-notification-enter-left; +} + +.overlay-notification-stack.position-top-left .overlay-notification-card.leaving { + animation-name: overlay-notification-leave-left; +} + +.overlay-notification-stack.position-top .overlay-notification-card.entering { + animation-name: overlay-notification-enter-top; +} + +.overlay-notification-stack.position-top .overlay-notification-card.leaving { + animation-name: overlay-notification-leave-top; +} + +.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-card.has-image { + /* Reserve a real track for the thumbnail so it never overlaps the text. + minmax(0, 100px) lets the column shrink the image on narrow notifications + instead of letting it spill into the content column. */ + grid-template-columns: minmax(0, 100px) minmax(0, 1fr) 22px; + min-height: 88px; +} + +.overlay-notification-image { + width: 100%; + max-width: 100px; + aspect-ratio: 100 / 56; + height: auto; + align-self: center; + display: block; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 28%, var(--ctp-surface2)); + background: var(--ctp-crust); + object-fit: cover; +} + +.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-right { + from { + opacity: 0; + transform: translateX(28px) scale(0.96); + } + + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes overlay-notification-enter-left { + from { + opacity: 0; + transform: translateX(-28px) scale(0.96); + } + + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes overlay-notification-enter-top { + from { + opacity: 0; + transform: translateY(-16px) scale(0.96); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes overlay-notification-leave-right { + from { + opacity: 1; + transform: translateX(0) scale(1); + } + + to { + opacity: 0; + transform: translateX(28px) scale(0.94); + } +} + +@keyframes overlay-notification-leave-left { + from { + opacity: 1; + transform: translateX(0) scale(1); + } + + to { + opacity: 0; + transform: translateX(-28px) scale(0.94); + } +} + +@keyframes overlay-notification-leave-top { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + + to { + opacity: 0; + transform: translateY(-14px) scale(0.94); + } +} + +@media (prefers-reduced-motion: reduce) { + .overlay-notification-card.entering, + .overlay-notification-card.leaving { + animation-duration: 1ms; + } +} + +@keyframes overlay-notification-spin { + to { + transform: rotate(360deg); + } +} + +/* Notification history panel — slides in from the same edge the notifications use. */ +.notification-history { + --notification-history-width: min(380px, calc(100vw - 24px)); + + position: absolute; + top: 0; + bottom: 0; + width: var(--notification-history-width); + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--ctp-mantle) 94%, transparent); + border: 1px solid var(--ctp-surface0); + box-shadow: 0 18px 48px -18px rgba(24, 25, 38, 0.85); + color: var(--ctp-text); + pointer-events: auto; + z-index: 2147483646; + opacity: 0; + visibility: hidden; + transition: + transform 240ms cubic-bezier(0.21, 1.02, 0.73, 1), + opacity 200ms ease, + visibility 0s linear 240ms; +} + +.notification-history.side-left { + left: 0; + right: auto; + border-left: none; + border-top-right-radius: 14px; + border-bottom-right-radius: 14px; + transform: translateX(-104%); +} + +.notification-history.side-right { + left: auto; + right: 0; + border-right: none; + border-top-left-radius: 14px; + border-bottom-left-radius: 14px; + transform: translateX(104%); +} + +.notification-history.open { + opacity: 1; + visibility: visible; + transform: translateX(0); + transition: + transform 260ms cubic-bezier(0.21, 1.02, 0.73, 1), + opacity 200ms ease, + visibility 0s linear 0s; +} + +.notification-history-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--ctp-surface0); + background: color-mix(in srgb, var(--ctp-crust) 60%, transparent); +} + +.notification-history-title { + font-size: 14px; + font-weight: 800; + letter-spacing: 0.2px; + color: var(--ctp-lavender); +} + +.notification-history-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.notification-history-clear { + padding: 5px 12px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--ctp-mauve) 38%, var(--ctp-surface1)); + background: color-mix(in srgb, var(--ctp-mauve) 14%, 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, + opacity 120ms ease; +} + +.notification-history-clear:hover:not(:disabled) { + border-color: var(--ctp-mauve); + background: color-mix(in srgb, var(--ctp-mauve) 26%, var(--ctp-surface0)); +} + +.notification-history-clear:disabled { + opacity: 0.4; + cursor: default; +} + +.notification-history-close { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: none; + border-radius: 7px; + background: transparent; + color: var(--ctp-overlay1); + font: inherit; + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.notification-history-close:hover { + background: color-mix(in srgb, var(--ctp-red) 18%, transparent); + color: var(--ctp-red); +} + +.notification-history-body { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px; + scrollbar-width: thin; + scrollbar-color: var(--ctp-surface2) transparent; +} + +.notification-history-body::-webkit-scrollbar { + width: 8px; +} + +.notification-history-body::-webkit-scrollbar-thumb { + background: var(--ctp-surface1); + border-radius: 8px; +} + +.notification-history-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; +} + +.notification-history-item { + --notification-history-accent: var(--ctp-blue); + + position: relative; + display: grid; + grid-template-columns: 4px auto minmax(0, 1fr) 22px; + gap: 10px; + align-items: start; + padding: 11px 12px; + border-radius: 10px; + border: 1px solid var(--ctp-surface0); + background: var(--ctp-base); +} + +.notification-history-item::before { + content: ''; + align-self: stretch; + border-radius: 4px; + background: var(--notification-history-accent); +} + +.notification-history-item.info { + --notification-history-accent: var(--ctp-blue); +} +.notification-history-item.progress { + --notification-history-accent: var(--ctp-sky); +} +.notification-history-item.success { + --notification-history-accent: var(--ctp-green); +} +.notification-history-item.warning { + --notification-history-accent: var(--ctp-yellow); +} +.notification-history-item.error { + --notification-history-accent: var(--ctp-red); +} + +.notification-history-thumb { + width: 56px; + aspect-ratio: 100 / 56; + height: auto; + align-self: center; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface2)); + background: var(--ctp-crust); + object-fit: cover; +} + +.notification-history-icon { + width: 10px; + height: 10px; + align-self: center; + border-radius: 50%; + background: var(--notification-history-accent); +} + +.notification-history-content { + min-width: 0; +} + +.notification-history-item-title { + font-size: 13px; + font-weight: 700; + line-height: 1.3; + color: var(--ctp-text); +} + +.notification-history-item-body { + margin-top: 3px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + color: var(--ctp-subtext0); + overflow-wrap: anywhere; +} + +.notification-history-time { + display: block; + margin-top: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--ctp-overlay1); +} + +.notification-history-actions { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 9px; +} + +.notification-history-action { + min-height: 24px; + max-width: 100%; + padding: 4px 9px; + border: 1px solid color-mix(in srgb, var(--notification-history-accent) 38%, var(--ctp-surface2)); + border-radius: 6px; + background: color-mix(in srgb, var(--notification-history-accent) 18%, var(--ctp-surface0)); + color: var(--ctp-text); + font: inherit; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.notification-history-action:hover { + border-color: color-mix(in srgb, var(--notification-history-accent) 70%, var(--ctp-surface2)); + background: color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface0)); +} + +.notification-history-remove { + width: 22px; + height: 22px; + align-self: start; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ctp-overlay1); + font: inherit; + font-size: 15px; + line-height: 1; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; +} + +.notification-history-remove:hover { + background: color-mix(in srgb, var(--ctp-red) 18%, transparent); + color: var(--ctp-red); +} + +.notification-history-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 96px; + padding: 24px; + text-align: center; + font-size: 13px; + font-weight: 500; + color: var(--ctp-overlay0); +} + +.notification-history-empty.hidden { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .notification-history { + transition-duration: 1ms; + } +} + .modal { position: absolute; inset: 0; @@ -1282,7 +1932,7 @@ iframe.yomitan-popup, iframe[id^='yomitan-popup'], [data-subminer-yomitan-popup-host='true'] { pointer-events: auto !important; - z-index: 2147483647 !important; + z-index: 2147483645; } .kiku-info-text { diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index bbddb2ac..b0fcc051 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -2,6 +2,8 @@ export type RendererDom = { subtitleRoot: HTMLElement; subtitleContainer: HTMLElement; overlay: HTMLElement; + overlayNotificationStack: HTMLDivElement; + overlayNotificationHistory: HTMLElement; controllerStatusToast: HTMLDivElement; overlayErrorToast: HTMLDivElement; secondarySubContainer: HTMLElement; @@ -132,6 +134,8 @@ export function resolveRendererDom(): RendererDom { subtitleRoot: getRequiredElement('subtitleRoot'), subtitleContainer: getRequiredElement('subtitleContainer'), overlay: getRequiredElement('overlay'), + overlayNotificationStack: getRequiredElement('overlayNotificationStack'), + overlayNotificationHistory: getRequiredElement('overlayNotificationHistory'), 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..dc73d7f6 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', @@ -61,6 +62,7 @@ export const IPC_CHANNELS = { getConfigShortcuts: 'get-config-shortcuts', getStatsToggleKey: 'get-stats-toggle-key', getMarkWatchedKey: 'get-mark-watched-key', + getOverlayNotificationPosition: 'get-overlay-notification-position', getControllerConfig: 'get-controller-config', getSecondarySubMode: 'get-secondary-sub-mode', getCurrentSecondarySub: 'get-current-secondary-sub', @@ -144,6 +146,8 @@ export const IPC_CHANNELS = { subtitleSidebarToggle: 'subtitle-sidebar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', configHotReload: 'config:hot-reload', + overlayNotification: 'overlay:notification', + notificationHistoryToggle: 'notification-history:toggle', }, } as const; diff --git a/src/shared/ipc/validators.test.ts b/src/shared/ipc/validators.test.ts new file mode 100644 index 00000000..057f4496 --- /dev/null +++ b/src/shared/ipc/validators.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config/definitions'; +import { compileSessionBindings } from '../../core/services/session-bindings'; +import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import { parseSessionActionDispatchRequest } from './validators'; + +// Regression guard: SESSION_ACTION_IDS in validators.ts is a hand-maintained mirror of the +// SessionActionId union. If a new shortcut-backed action is added to the union/defaults but not to +// the validator allow-list, the renderer's dispatchSessionAction IPC is rejected at runtime (which +// surfaces as a "Renderer error recovered" toast). Compile every default binding and assert the +// validator accepts each one so the two lists can't silently drift apart. +test('every default session-action binding is accepted by parseSessionActionDispatchRequest', () => { + const { bindings } = compileSessionBindings({ + shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG), + keybindings: DEFAULT_KEYBINDINGS, + statsToggleKey: DEFAULT_CONFIG.stats.toggleKey, + statsMarkWatchedKey: DEFAULT_CONFIG.stats.markWatchedKey, + platform: 'linux', + rawConfig: DEFAULT_CONFIG, + }); + + const sessionActions = bindings.filter((binding) => binding.actionType === 'session-action'); + assert.ok(sessionActions.length > 0, 'expected default session-action bindings to exist'); + + for (const binding of sessionActions) { + if (binding.actionType !== 'session-action') continue; + const request = + binding.payload === undefined + ? { actionId: binding.actionId } + : { actionId: binding.actionId, payload: binding.payload }; + assert.ok( + parseSessionActionDispatchRequest(request) !== null, + `validator rejected session action: ${binding.actionId}`, + ); + } +}); + +test('toggleNotificationHistory dispatch request is accepted', () => { + assert.deepEqual(parseSessionActionDispatchRequest({ actionId: 'toggleNotificationHistory' }), { + actionId: 'toggleNotificationHistory', + }); +}); diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 4d7ed083..968c3a93 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -20,6 +20,7 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr const SESSION_ACTION_IDS: SessionActionId[] = [ 'toggleStatsOverlay', + 'markWatched', 'toggleVisibleOverlay', 'copySubtitle', 'copySubtitleMultiple', @@ -31,6 +32,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [ 'toggleSecondarySub', 'markAudioCard', 'toggleSubtitleSidebar', + 'toggleNotificationHistory', 'openRuntimeOptions', 'openSessionHelp', 'openCharacterDictionaryManager', diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index 3aacdf18..eab9840c 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -10,9 +10,13 @@ export interface SubminerPluginRuntimeScriptOptConfig { autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + overlayLoadingOsd?: boolean; + osdMessages: boolean; texthookerEnabled: boolean; } +const AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS = 30; + function boolScriptOpt(value: boolean): 'yes' | 'no' { return value ? 'yes' : 'no'; } @@ -32,15 +36,21 @@ export function buildSubminerPluginRuntimeScriptOptParts( const binaryPath = sanitizeScriptOptValue(runtimeConfig.binaryPath?.trim() || fallbackAppPath); const socketPath = sanitizeScriptOptValue(runtimeConfig.socketPath); const backend = sanitizeScriptOptValue(runtimeConfig.backend); + const overlayLoadingOsd = + runtimeConfig.overlayLoadingOsd ?? + (runtimeConfig.autoStart && runtimeConfig.autoStartVisibleOverlay); return [ `subminer-binary_path=${binaryPath}`, `subminer-socket_path=${socketPath}`, `subminer-backend=${backend}`, `subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`, `subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`, + `subminer-overlay_loading_osd=${boolScriptOpt(overlayLoadingOsd)}`, `subminer-auto_start_pause_until_ready=${boolScriptOpt( runtimeConfig.autoStartPauseUntilReady, )}`, + `subminer-auto_start_pause_until_ready_timeout_seconds=${AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS}`, + `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..ff37ee5d 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 { @@ -120,6 +125,7 @@ export interface ShortcutsConfig { openControllerSelect?: string | null; openControllerDebug?: string | null; toggleSubtitleSidebar?: string | null; + toggleNotificationHistory?: string | null; } export interface Config { @@ -149,6 +155,7 @@ export interface Config { immersionTracking?: ImmersionTrackingConfig; stats?: StatsConfig; updates?: UpdatesConfig; + notifications?: NotificationsConfig; logging?: { level?: 'debug' | 'info' | 'warn' | 'error'; rotation?: LogRotation; @@ -247,7 +254,7 @@ export interface ResolvedConfig { overwriteImage: boolean; mediaInsertMode: 'append' | 'prepend'; highlightWord: boolean; - notificationType: 'osd' | 'system' | 'both' | 'none'; + notificationType: NotificationType; autoUpdateNewCards: boolean; }; metadata: { @@ -379,6 +386,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..69222ee7 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,58 @@ +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 const OPEN_ANKI_CARD_ACTION_ID = 'open-anki-card'; + +export interface OverlayNotificationAction { + id: string; + label: string; + noteId?: number; +} + +export interface OverlayNotificationPayload { + id?: string; + historyId?: string; + title: string; + body?: string; + image?: 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..23d26fc2 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -41,6 +41,11 @@ import type { RuntimeOptionState, RuntimeOptionValue, } from './runtime-options'; +import type { + OverlayNotificationAction, + OverlayNotificationEventPayload, + OverlayNotificationPosition, +} from './notification'; export interface WindowGeometry { x: number; @@ -405,6 +410,13 @@ 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, + options?: Pick, + ) => void; + onNotificationHistoryToggle: (callback: () => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; getOverlayVisibility: () => Promise; @@ -433,6 +445,7 @@ export interface ElectronAPI { ) => Promise; getStatsToggleKey: () => Promise; getMarkWatchedKey: () => Promise; + getOverlayNotificationPosition: () => Promise; markActiveVideoWatched: () => Promise; getControllerConfig: () => Promise; saveControllerConfig: (update: ControllerConfigUpdate) => Promise; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index 5a76ad53..1a10ddcc 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -13,6 +13,7 @@ export type SessionActionId = | 'mineSentenceMultiple' | 'toggleSecondarySub' | 'toggleSubtitleSidebar' + | 'toggleNotificationHistory' | 'markAudioCard' | 'openRuntimeOptions' | 'openSessionHelp'