From 8111deac441af122d27e72376a39f3ed7e86550e Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 15:29:14 -0700 Subject: [PATCH] feat(notifications): add notification history panel and overlay UX fixes - New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack - Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup - Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish - Add Update button to overlay update-available notifications - Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast - Fix overlay notification close/actions clickability above subtitle bars on Linux - Increase pause-until-ready default timeout from 15s to 30s --- changes/overlay-notifications.md | 6 + config.example.jsonc | 3 +- docs-site/configuration.md | 74 +++-- docs-site/mpv-plugin.md | 28 +- docs/architecture/subtitle-overlay-priming.md | 5 + ...06-early-managed-overlay-startup-design.md | 29 ++ launcher/config-domain-parsers.test.ts | 2 + plugin/subminer/options.lua | 2 +- plugin/subminer/process.lua | 2 +- plugin/subminer/session_bindings.lua | 2 + scripts/test-plugin-start-gate.lua | 26 ++ .../card-creation-manual-update.test.ts | 25 ++ src/anki-integration/card-creation.ts | 1 - src/config/definitions/defaults-core.ts | 1 + src/config/definitions/options-core.ts | 6 + src/config/resolve/core-domains.ts | 1 + src/config/settings/registry.ts | 1 + src/core/services/app-ready.test.ts | 69 ++++ src/core/services/ipc.test.ts | 31 ++ src/core/services/ipc.ts | 40 +++ .../services/overlay-shortcut-handler.test.ts | 1 + src/core/services/overlay-shortcut.test.ts | 1 + src/core/services/session-actions.test.ts | 1 + src/core/services/session-actions.ts | 4 + src/core/services/session-bindings.test.ts | 6 +- src/core/services/session-bindings.ts | 1 + src/core/services/startup.ts | 35 +- src/core/utils/shortcut-config.ts | 2 + src/main.ts | 30 +- src/main/app-lifecycle.ts | 3 + src/main/dependencies.ts | 4 + src/main/main-wiring.test.ts | 9 + src/main/runtime/app-ready-main-deps.test.ts | 2 + src/main/runtime/app-ready-main-deps.ts | 2 + src/main/runtime/autoplay-ready-gate.test.ts | 43 +++ src/main/runtime/autoplay-ready-gate.ts | 2 +- .../composers/ipc-runtime-composer.test.ts | 1 + .../global-shortcuts-runtime-handlers.test.ts | 1 + src/main/runtime/global-shortcuts.test.ts | 1 + .../startup-autoplay-release-policy.test.ts | 1 + .../startup-autoplay-release-policy.ts | 2 +- src/main/runtime/startup-mode-flags.test.ts | 12 + src/main/runtime/startup-mode-flags.ts | 6 + .../update/update-notifications.test.ts | 22 ++ .../runtime/update/update-notifications.ts | 6 + .../runtime/update/update-service.test.ts | 22 ++ src/main/runtime/update/update-service.ts | 9 +- src/preload.ts | 7 + src/renderer/handlers/keyboard.test.ts | 3 + src/renderer/index.html | 25 ++ src/renderer/modals/session-help-sections.ts | 3 + src/renderer/overlay-content-measurement.ts | 7 + src/renderer/overlay-mouse-ignore.ts | 1 + .../overlay-notification-history.test.ts | 100 ++++++ src/renderer/overlay-notification-history.ts | 241 ++++++++++++++ src/renderer/overlay-notifications.test.ts | 95 +++++- src/renderer/overlay-notifications.ts | 3 +- src/renderer/renderer.ts | 41 +++ src/renderer/state.ts | 4 + src/renderer/style.css | 305 +++++++++++++++++- src/renderer/utils/dom.ts | 2 + src/shared/ipc/contracts.ts | 2 + src/shared/ipc/validators.test.ts | 44 +++ src/shared/ipc/validators.ts | 2 + src/shared/subminer-plugin-script-opts.ts | 3 + src/types/config.ts | 1 + src/types/runtime.ts | 4 +- src/types/session-bindings.ts | 1 + 68 files changed, 1408 insertions(+), 69 deletions(-) create mode 100644 docs/plans/2026-06-06-early-managed-overlay-startup-design.md create mode 100644 src/renderer/overlay-notification-history.test.ts create mode 100644 src/renderer/overlay-notification-history.ts create mode 100644 src/shared/ipc/validators.test.ts diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md index e5a22ebf..1217c955 100644 --- a/changes/overlay-notifications.md +++ b/changes/overlay-notifications.md @@ -4,10 +4,16 @@ 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+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 seeded the notification stack and history panel side from that position at startup. - Routed startup tokenization and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`. +- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on cold managed background startup, while keeping playback paused until SubMiner reports autoplay readiness. - Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates. - Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback. - Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications. +- Added an Update button to overlay update-available notifications so users can start the app update flow from the notification. +- Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast. +- Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays. - Fixed character dictionary sync so duplicate MPV media-path events do not repeat check/ready notifications for the same opened video. - Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`. - Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected. diff --git a/config.example.jsonc b/config.example.jsonc index e2334ef5..defe8b7f 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -208,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": "Ctrl+N" // Accelerator that toggles the overlay notification history panel. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 9932530f..912be8a3 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -210,6 +210,8 @@ Configure automatic update checks and update notifications: | `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. `"both"` means overlay + system. | | `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. | +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 @@ -228,6 +230,10 @@ Configure where overlay notification cards appear: | ----------------- | ---------------------------------------- | ------------------------------------------------------------------ | | `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+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). 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 and subtitle annotation status follows the configured notification surface. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`. ### Auto-Start Overlay @@ -244,7 +250,7 @@ Control whether the overlay automatically becomes visible when it connects to mp | -------------------- | --------------- | ----------------------------------------------------- | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `true`) | -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 cold managed background 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`. @@ -641,31 +647,33 @@ See `config.example.jsonc` for detailed configuration options. "openControllerDebug": "Alt+Shift+C", "openJimaku": "Ctrl+Shift+J", "toggleSubtitleSidebar": "Backslash", + "toggleNotificationHistory": "Ctrl+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: `"Ctrl+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. @@ -1479,18 +1487,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/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index b3c317fe..a28154da 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -68,6 +68,11 @@ prefetch work and re-centers prefetch around the live playback time. - `mpv.pauseUntilOverlayReady` waits for tokenization warmup plus visible-overlay readiness before releasing the mpv startup gate. +- Cold `--start --background --managed-playback` launches handle initial args before the deferred + Yomitan wait, so the tray and visible overlay shell can receive startup notifications while + tokenization and annotation warmups continue. +- 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 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/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 40a86188..a55f9615 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -196,6 +196,7 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-osd_messages=yes', 'subminer-texthooker_enabled=no', ], @@ -224,6 +225,7 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri 'subminer-auto_start=yes', 'subminer-auto_start_visible_overlay=no', 'subminer-auto_start_pause_until_ready=yes', + 'subminer-auto_start_pause_until_ready_timeout_seconds=30', 'subminer-osd_messages=no', 'subminer-texthooker_enabled=no', ], diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index 163a5339..339dc91c 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -33,7 +33,7 @@ function M.load(options_lib, default_socket_path) auto_start_visible_overlay = 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..7b3b2806 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -6,7 +6,7 @@ local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20 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) 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/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index bf0be497..977d1081 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -1759,6 +1759,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-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/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 167ed03e..876eb794 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -102,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick< openControllerSelect: 'Alt+C', openControllerDebug: 'Alt+Shift+C', toggleSubtitleSidebar: 'Backslash', + toggleNotificationHistory: 'Ctrl+N', }, secondarySub: { secondarySubLanguages: [], diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 3fa71814..c86a7174 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -622,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/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 382b61a0..203998cb 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'openCharacterDictionaryManager', 'openRuntimeOptions', 'openJimaku', + 'toggleNotificationHistory', ] as const; for (const key of shortcutKeys) { diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index ebddab3e..a815379a 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -582,6 +582,7 @@ function subsectionForPath(path: string): string | undefined { if ( leaf === 'toggleVisibleOverlayGlobal' || leaf === 'toggleSubtitleSidebar' || + leaf === 'toggleNotificationHistory' || leaf === 'toggleSecondarySub' || leaf === 'toggleStatsOverlay' || leaf === 'markWatched' diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index d1a225e6..5ee92d0d 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,6 +281,71 @@ test('runAppReadyRuntime does not await background warmups', async () => { releaseWarmup(); }); +test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => { + const calls: string[] = []; + 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 before core runtime services', async () => { const calls: string[] = []; const { deps } = makeDeps({ diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index f47f8d4b..21401848 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,31 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () => ]); }); +test('registerIpcHandlers forwards valid overlay notification actions', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const actions: Array<{ notificationId: string; actionId: string }> = []; + registerIpcHandlers( + createRegisterIpcDeps({ + handleOverlayNotificationAction: (notificationId: string, actionId: string) => { + actions.push({ notificationId, actionId }); + }, + } 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: 'subminer-update-available', actionId: 'install-update' }); + + assert.deepEqual(actions, [ + { notificationId: 'subminer-update-available', actionId: 'install-update' }, + ]); +}); + test('registerIpcHandlers rejects malformed controller preference payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers( @@ -1289,6 +1319,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..023eabae 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -53,6 +53,10 @@ export interface IpcServiceDeps { interactive: boolean, senderWindow: ElectronBrowserWindow | null, ) => void; + handleOverlayNotificationAction?: ( + notificationId: string, + actionId: string, + ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -80,6 +84,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 +228,18 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n return parsed; } +function parseOverlayNotificationActionPayload( + payload: unknown, +): { notificationId: string; actionId: string } | null { + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + const notificationId = record.notificationId; + const actionId = record.actionId; + if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null; + if (typeof actionId !== 'string' || actionId.trim().length === 0) return null; + return { notificationId, actionId }; +} + export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; @@ -242,6 +259,10 @@ export interface IpcDepsRuntimeOptions { interactive: boolean, senderWindow: ElectronBrowserWindow | null, ) => void; + handleOverlayNotificationAction?: ( + notificationId: string, + actionId: string, + ) => void | Promise; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -262,6 +283,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 +334,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 +372,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 +497,18 @@ 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), + ).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 +677,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-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/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.ts b/src/core/services/startup.ts index 8f8c5b95..cf325ed5 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,23 @@ 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(); + }; + deps.ensureDefaultConfigBootstrap(); if (deps.shouldRunHeadlessInitialCommand?.()) { deps.reloadConfig(); @@ -247,7 +265,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup, shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup, + shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => + shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs), createImmersionTracker: () => { ensureImmersionTrackerStarted(); }, @@ -6707,6 +6718,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro mineSentenceCount: (count) => handleMineSentenceDigit(count), toggleSecondarySub: () => handleCycleSecondarySubMode(), toggleSubtitleSidebar: () => toggleSubtitleSidebar(), + toggleNotificationHistory: () => toggleNotificationHistoryPanel(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), markActiveVideoWatched: async () => { ensureImmersionTrackerStarted(); @@ -6855,6 +6867,21 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ linuxOverlayInteractiveHint = interactive; applyLinuxOverlayInputShapeFromLatestMeasurement(); }, + handleOverlayNotificationAction: (notificationId, actionId) => { + 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); + }); + } + }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context), @@ -6996,6 +7023,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(); 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/dependencies.ts b/src/main/dependencies.ts index c33e0b9a..edd3ff1f 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -60,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']; @@ -83,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']; @@ -243,6 +245,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, @@ -264,6 +267,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, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 2cefac31..b4624810 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -118,6 +118,15 @@ 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*=>/); + assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/); + assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/); + assert.match(source, /installWhenAvailable:\s*true/); +}); + test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => { const source = readMainSource(); const actionBlock = source.match( 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 a712ade2..0458057a 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -365,6 +365,49 @@ test('autoplay ready gate retries deferred readiness without an external flush e ); }); +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 2bf83926..65e77a67 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -2,7 +2,7 @@ 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 = 75; +const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150; type MpvClientLike = { connected?: boolean; 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/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/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/update/update-notifications.test.ts b/src/main/runtime/update/update-notifications.test.ts index 379b5b75..f0856283 100644 --- a/src/main/runtime/update/update-notifications.test.ts +++ b/src/main/runtime/update/update-notifications.test.ts @@ -36,6 +36,28 @@ test('notifyUpdateAvailable routes notification surfaces from config', async () ]); }); +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[] = []; diff --git a/src/main/runtime/update/update-notifications.ts b/src/main/runtime/update/update-notifications.ts index 14d0182b..850f5c1f 100644 --- a/src/main/runtime/update/update-notifications.ts +++ b/src/main/runtime/update/update-notifications.ts @@ -1,6 +1,9 @@ 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; @@ -17,9 +20,12 @@ export async function notifyUpdateAvailable( const message = `SubMiner v${options.version} is available`; 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 === 'osd-system') { diff --git a/src/main/runtime/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index da01f7a7..368ce69c 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 }), diff --git a/src/main/runtime/update/update-service.ts b/src/main/runtime/update/update-service.ts index f6382fb9..035078e2 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 = @@ -164,9 +165,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; diff --git a/src/preload.ts b/src/preload.ts index bf5d6808..f1828c83 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -60,6 +60,7 @@ import type { YoutubePickerResolveRequest, YoutubePickerResolveResult, OverlayNotificationEventPayload, + OverlayNotificationPosition, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -212,6 +213,9 @@ const onOverlayNotificationEvent = 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, @@ -239,6 +243,7 @@ const electronAPI: ElectronAPI = { sendOverlayNotificationAction: (notificationId: string, actionId: string) => { ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId }); }, + onNotificationHistoryToggle: onNotificationHistoryToggleEvent, onVisibility: (callback: (visible: boolean) => void) => { onSubtitleVisibilityEvent(callback); @@ -312,6 +317,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 0860b824..4ea76660 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -48,6 +48,31 @@ aria-live="polite" aria-atomic="false" > +
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.ts b/src/renderer/overlay-content-measurement.ts index 9fb30cb7..ffd5f4aa 100644 --- a/src/renderer/overlay-content-measurement.ts +++ b/src/renderer/overlay-content-measurement.ts @@ -85,6 +85,13 @@ function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] { } } + 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 8ff19797..f06584ba 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -32,6 +32,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || ctx.state.isOverOverlayNotification || + ctx.state.isOverNotificationHistory || shouldKeepWindowInteractive; const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform ? shouldKeepWindowInteractive diff --git a/src/renderer/overlay-notification-history.test.ts b/src/renderer/overlay-notification-history.test.ts new file mode 100644 index 00000000..c41ee866 --- /dev/null +++ b/src/renderer/overlay-notification-history.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { OverlayNotificationEntry } from './overlay-notifications'; +import { + 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 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('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'); +}); diff --git a/src/renderer/overlay-notification-history.ts b/src/renderer/overlay-notification-history.ts new file mode 100644 index 00000000..04c65c8c --- /dev/null +++ b/src/renderer/overlay-notification-history.ts @@ -0,0 +1,241 @@ +import type { 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; + 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 id so a + * progress notification that updates in place (same id, new body) overwrites its record rather than + * piling up duplicates. 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 existing = entries.get(entry.id); + const next: OverlayNotificationHistoryEntry = { + id: entry.id, + title: entry.title, + body: entry.body, + image: entry.image, + variant: normalizeVariant(entry.variant), + 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(entry.id, 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); + + 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)); + + 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, + }; +} diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts index f9262000..5f0b1ae1 100644 --- a/src/renderer/overlay-notifications.test.ts +++ b/src/renderer/overlay-notifications.test.ts @@ -41,13 +41,16 @@ type FakeElement = { classList: ReturnType; 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: '', @@ -68,7 +71,13 @@ function createFakeElement(tagName = 'div'): FakeElement { attributes.set(name, value); }, getAttribute: (name) => attributes.get(name) ?? null, - addEventListener: () => 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; } @@ -197,11 +206,90 @@ test('overlay notification renderer shows thumbnail image from payload', () => { } }); +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 }> = []; + + 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) => { + sentActions.push({ notificationId, actionId }); + }, + }, + }, + }); + + 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: 'install-update', label: 'Update' }], + }); + + 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: 'install-update' }, + ]); + } 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, @@ -213,7 +301,10 @@ test('overlay notification cards use larger display dimensions', () => { 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*\{[^}]*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 index 836763e4..8d27464c 100644 --- a/src/renderer/overlay-notifications.ts +++ b/src/renderer/overlay-notifications.ts @@ -145,7 +145,7 @@ function setInteractiveState(ctx: RendererContext, value: boolean): void { export function createOverlayNotificationRenderer( ctx: RendererContext, - options: { onChanged?: () => void } = {}, + options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {}, ) { const store = createOverlayNotificationStore(); const timers = new Map(); @@ -321,6 +321,7 @@ export function createOverlayNotificationRenderer( 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); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 9b12f8d4..5c29ebd3 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -48,7 +48,9 @@ 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'; @@ -116,8 +118,12 @@ function syncSettingsModalSubtitleSuppression(): void { const subtitleRenderer = createSubtitleRenderer(ctx); const measurementReporter = createOverlayContentMeasurementReporter(ctx); +const notificationHistory = createOverlayNotificationHistoryPanel(ctx, { + onChanged: () => measurementReporter.schedule(), +}); const overlayNotifications = createOverlayNotificationRenderer(ctx, { onChanged: () => measurementReporter.schedule(), + onShow: (entry) => notificationHistory.record(entry), }); const positioning = createPositioningController(ctx); const runtimeOptionsModal = createRuntimeOptionsModal(ctx, { @@ -432,12 +438,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'); @@ -624,9 +648,26 @@ async function init(): Promise { handleOverlayNotificationEvent(overlayNotifications, payload); }); }); + window.electronAPI.onNotificationHistoryToggle(() => { + runGuarded('notification-history:toggle', () => { + notificationHistory.toggle(); + }); + }); await keyboardHandlers.setupMpvInputForwarding(); + // Seed the notification stack position from config so the stack, error/status toasts, and the + // notification history panel side are correct before the first notification arrives. + try { + const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition(); + ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES); + ctx.dom.overlayNotificationStack.classList.add( + overlayNotificationPositionClass(overlayNotificationPosition), + ); + } catch { + // Non-fatal: keep the default position class from index.html. + } + const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle(); subtitleRenderer.applySubtitleStyle(initialSubtitleStyle); subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible'); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 749d7063..34bd575b 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -32,6 +32,8 @@ export type RendererState = { isOverSubtitle: boolean; isOverSubtitleSidebar: boolean; isOverOverlayNotification: boolean; + isOverNotificationHistory: boolean; + notificationHistoryOpen: boolean; isDragging: boolean; dragStartY: number; startYPercent: number; @@ -145,6 +147,8 @@ export function createRendererState(): RendererState { 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 986cefc8..a5bc306d 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -146,6 +146,27 @@ 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; @@ -154,7 +175,7 @@ body:focus-visible, flex-direction: column; gap: 8px; pointer-events: auto; - z-index: 1350; + z-index: 2147483647 !important; } .overlay-notification-stack.position-top-left { @@ -461,6 +482,288 @@ body:focus-visible, } } +/* 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-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; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 5ea6e8e0..b0fcc051 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -3,6 +3,7 @@ export type RendererDom = { subtitleContainer: HTMLElement; overlay: HTMLElement; overlayNotificationStack: HTMLDivElement; + overlayNotificationHistory: HTMLElement; controllerStatusToast: HTMLDivElement; overlayErrorToast: HTMLDivElement; secondarySubContainer: HTMLElement; @@ -134,6 +135,7 @@ export function resolveRendererDom(): RendererDom { 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/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index bb82ffc7..dc73d7f6 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -62,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', @@ -146,6 +147,7 @@ export const IPC_CHANNELS = { 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 62c56c99..a5bca146 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -14,6 +14,8 @@ export interface SubminerPluginRuntimeScriptOptConfig { texthookerEnabled: boolean; } +const AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS = 30; + function boolScriptOpt(value: boolean): 'yes' | 'no' { return value ? 'yes' : 'no'; } @@ -42,6 +44,7 @@ export function buildSubminerPluginRuntimeScriptOptParts( `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/config.ts b/src/types/config.ts index eeae06ea..ff37ee5d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -125,6 +125,7 @@ export interface ShortcutsConfig { openControllerSelect?: string | null; openControllerDebug?: string | null; toggleSubtitleSidebar?: string | null; + toggleNotificationHistory?: string | null; } export interface Config { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 71b0350a..1e4c4258 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -41,7 +41,7 @@ import type { RuntimeOptionState, RuntimeOptionValue, } from './runtime-options'; -import type { OverlayNotificationEventPayload } from './notification'; +import type { OverlayNotificationEventPayload, OverlayNotificationPosition } from './notification'; export interface WindowGeometry { x: number; @@ -408,6 +408,7 @@ export interface ElectronAPI { onOverlayPointerRecoveryRequested: (callback: () => void) => void; onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void; sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void; + onNotificationHistoryToggle: (callback: () => void) => void; onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; getOverlayVisibility: () => Promise; @@ -436,6 +437,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'