mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 03:13:32 -07:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
// ==========================================
|
||||
|
||||
+41
-33
@@ -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=<name>`. 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 and skip markers in the bundled mpv plugin (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||
| 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=<name>`. 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 and skip markers in the bundled mpv plugin (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
|
||||
+14
-14
@@ -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. 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.
|
||||
|
||||
@@ -166,7 +166,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused. On cold managed background startup, SubMiner opens the tray and visible overlay shell before tokenization warmups finish, then the plugin resumes playback after SubMiner reports tokenization-ready (with a 30-second timeout fallback).
|
||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!-- read_when: changing managed mpv startup, pause-until-ready, or visible overlay boot ordering -->
|
||||
|
||||
# Early Managed Overlay Startup Design
|
||||
|
||||
Status: approved
|
||||
Date: 2026-06-06
|
||||
|
||||
## Problem
|
||||
|
||||
Managed mpv startup can pause playback immediately, then leave SubMiner's tray and visible overlay
|
||||
unavailable until Yomitan/tokenization warmups finish. Startup notifications therefore miss the
|
||||
overlay surface and fall back to non-overlay status paths.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
For cold `--start --background --managed-playback` launches, handle initial args before waiting for
|
||||
the deferred overlay warmup. That lets the tray and visible overlay shell initialize immediately
|
||||
while the existing tokenization warmups continue in the background.
|
||||
|
||||
The mpv plugin pause gate stays armed. Playback release still waits for SubMiner's autoplay-ready
|
||||
signal, which is emitted only after tokenization warmup and visible-overlay readiness. Existing
|
||||
second-instance attach behavior remains unchanged: when the launcher finds an already-running
|
||||
background app, it sends the same control command to that process and reuses its warmups/tokenizer.
|
||||
|
||||
## Checks
|
||||
|
||||
- Add a startup ordering regression test for managed background playback.
|
||||
- Keep the existing deferred startup ordering for non-managed launches.
|
||||
- Run the startup/runtime test slice plus SubMiner verification lane.
|
||||
@@ -208,6 +208,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',
|
||||
'subminer-aniskip_enabled=no',
|
||||
@@ -240,6 +241,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',
|
||||
'subminer-aniskip_enabled=no',
|
||||
|
||||
@@ -34,7 +34,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",
|
||||
aniskip_enabled = false,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1915,6 +1915,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 = "",
|
||||
|
||||
@@ -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 ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const progressMessages: string[] = [];
|
||||
const { service } = createManualUpdateService({
|
||||
showOsdNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
withUpdateProgress: async (message, action) => {
|
||||
progressMessages.push(message);
|
||||
return await action();
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
assert.deepEqual(statusMessages, []);
|
||||
});
|
||||
|
||||
@@ -511,7 +511,6 @@ export class CardCreationService {
|
||||
endTime = startTime + maxMediaDuration;
|
||||
}
|
||||
|
||||
this.deps.showOsdNotification('Creating sentence card...');
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
|
||||
@@ -102,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'Ctrl+N',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'toggleNotificationHistory',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
|
||||
@@ -582,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleNotificationHistory' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
|
||||
@@ -2,6 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function waitTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
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<void>((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<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
try {
|
||||
assert.ok(calls.includes('handleFirstRunSetup'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.equal(calls.includes('loadYomitanExtension:done'), false);
|
||||
} finally {
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
assert.equal(calls.includes('handleInitialArgs'), false);
|
||||
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
|
||||
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): 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<IpcServiceDeps>),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
|
||||
assert.ok(actionHandler);
|
||||
|
||||
actionHandler({}, null);
|
||||
actionHandler({}, { notificationId: '', actionId: 'install-update' });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
|
||||
actionHandler({}, { notificationId: '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 () => {},
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface IpcServiceDeps {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -80,6 +84,7 @@ export interface IpcServiceDeps {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -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<string, unknown>;
|
||||
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<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -262,6 +283,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
mineSentenceCount: (count) => calls.push(`mine:${count}`),
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
toggleNotificationHistory: () => calls.push('notification-history'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
|
||||
mineSentenceCount: (count: number) => void;
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
toggleNotificationHistory: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
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;
|
||||
|
||||
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): 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'],
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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<voi
|
||||
const startupStartedAtMs = now();
|
||||
const ensureYomitanExtensionReady =
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
let firstRunSetupHandled = false;
|
||||
let initialArgsHandled = false;
|
||||
const handleFirstRunSetupOnce = async (): Promise<void> => {
|
||||
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<voi
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
handleInitialArgsOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,8 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
}
|
||||
@@ -332,10 +350,15 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.initializeOverlayRuntime();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await ensureYomitanExtensionReady();
|
||||
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
} else {
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
toggleSubtitleSidebar: string | null | undefined;
|
||||
toggleNotificationHistory: string | null | undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredShortcuts(
|
||||
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
|
||||
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
|
||||
};
|
||||
}
|
||||
|
||||
+29
-1
@@ -190,6 +190,7 @@ import {
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
@@ -602,7 +603,11 @@ import {
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import {
|
||||
INSTALL_UPDATE_ACTION_ID,
|
||||
notifyUpdateAvailable,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
@@ -3364,6 +3369,10 @@ function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
|
||||
function showConfiguredStatusNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
@@ -5079,6 +5088,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -6675,6 +6686,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: async () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6823,6 +6835,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),
|
||||
@@ -6964,6 +6991,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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -45,5 +45,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
|
||||
deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => false,
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
for (let attempt = 1; attempt <= 100; attempt += 1) {
|
||||
assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
|
||||
scheduled.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>(
|
||||
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<string> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
|
||||
getOverlayNotificationPosition: (): Promise<OverlayNotificationPosition> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayNotificationPosition),
|
||||
getMarkWatchedKey: (): Promise<string> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getMarkWatchedKey),
|
||||
markActiveVideoWatched: (): Promise<boolean> =>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -48,6 +48,31 @@
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
></div>
|
||||
<aside
|
||||
id="overlayNotificationHistory"
|
||||
class="notification-history side-right"
|
||||
role="dialog"
|
||||
aria-label="Notification history"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<header class="notification-history-header">
|
||||
<span class="notification-history-title">Notifications</span>
|
||||
<div class="notification-history-header-actions">
|
||||
<button class="notification-history-clear" type="button">Clear</button>
|
||||
<button
|
||||
class="notification-history-close"
|
||||
type="button"
|
||||
aria-label="Close notification history"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="notification-history-body">
|
||||
<ul class="notification-history-list"></ul>
|
||||
<div class="notification-history-empty">No notifications yet</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
||||
<div id="secondarySubRoot"></div>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OverlayNotificationEntry> & { 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');
|
||||
});
|
||||
@@ -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<string, OverlayNotificationHistoryEntry>();
|
||||
|
||||
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<HTMLUListElement>('.notification-history-list');
|
||||
const empty = panel.querySelector<HTMLElement>('.notification-history-empty');
|
||||
const clearButton = panel.querySelector<HTMLButtonElement>('.notification-history-clear');
|
||||
const closeButton = panel.querySelector<HTMLButtonElement>('.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,
|
||||
};
|
||||
}
|
||||
@@ -41,13 +41,16 @@ type FakeElement = {
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
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<string, string>();
|
||||
const listeners = new Map<string, Array<(event?: unknown) => 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,
|
||||
|
||||
@@ -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<string, number>();
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
+304
-1
@@ -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;
|
||||
|
||||
@@ -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<HTMLElement>('subtitleContainer'),
|
||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||
overlayNotificationStack: getRequiredElement<HTMLDivElement>('overlayNotificationStack'),
|
||||
overlayNotificationHistory: getRequiredElement<HTMLElement>('overlayNotificationHistory'),
|
||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
const AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS = 30;
|
||||
|
||||
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
||||
return value ? 'yes' : 'no';
|
||||
}
|
||||
@@ -45,6 +47,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)}`,
|
||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface ShortcutsConfig {
|
||||
openControllerSelect?: string | null;
|
||||
openControllerDebug?: string | null;
|
||||
toggleSubtitleSidebar?: string | null;
|
||||
toggleNotificationHistory?: string | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -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<boolean>;
|
||||
@@ -436,6 +437,7 @@ export interface ElectronAPI {
|
||||
) => Promise<void>;
|
||||
getStatsToggleKey: () => Promise<string>;
|
||||
getMarkWatchedKey: () => Promise<string>;
|
||||
getOverlayNotificationPosition: () => Promise<OverlayNotificationPosition>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SessionActionId =
|
||||
| 'mineSentenceMultiple'
|
||||
| 'toggleSecondarySub'
|
||||
| 'toggleSubtitleSidebar'
|
||||
| 'toggleNotificationHistory'
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
|
||||
Reference in New Issue
Block a user