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:
2026-06-06 15:29:14 -07:00
parent 501304e451
commit d033884b09
68 changed files with 1408 additions and 69 deletions
+6
View File
@@ -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.
+2 -1
View File
@@ -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.
// ==========================================
+12 -4
View File
@@ -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,13 +647,14 @@ 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"`) |
@@ -666,6 +673,7 @@ See `config.example.jsonc` for detailed configuration options.
| `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.
@@ -1480,14 +1488,14 @@ 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`) |
| `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"`) |
+3 -3
View File
@@ -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.
@@ -30,7 +30,7 @@ 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 |
@@ -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.
+2
View File
@@ -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',
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+2
View File
@@ -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
+26
View File
@@ -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, []);
});
-1
View File
@@ -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');
+1
View File
@@ -102,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: 'Backslash',
toggleNotificationHistory: 'Ctrl+N',
},
secondarySub: {
secondarySubLanguages: [],
+6
View File
@@ -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.',
},
];
}
+1
View File
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'toggleNotificationHistory',
] as const;
for (const key of shortcutKeys) {
+1
View File
@@ -582,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleNotificationHistory' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
+69
View File
@@ -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({
+31
View File
@@ -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 () => {},
+40
View File
@@ -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');
},
+4
View File
@@ -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;
+5 -1
View File
@@ -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'],
+1
View File
@@ -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[] {
+28 -5
View File
@@ -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.');
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.`);
}
+2
View File
@@ -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
View File
@@ -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();
+3
View File
@@ -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,
};
}
+4
View File
@@ -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,
+9
View File
@@ -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();
+2
View File
@@ -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;
+1 -1
View File
@@ -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);
});
+6
View File
@@ -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,10 +165,12 @@ export function createUpdateService(deps: UpdateServiceDeps) {
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;
let appUpdateApplied = false;
+7
View File
@@ -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> =>
+3
View File
@@ -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('');
+25
View File
@@ -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;
}
+1
View File
@@ -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,
};
}
+93 -2
View File
@@ -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,
+2 -1
View File
@@ -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);
+41
View File
@@ -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');
+4
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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'),
+2
View File
@@ -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;
+44
View File
@@ -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',
});
});
+2
View File
@@ -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)}`,
+1
View File
@@ -125,6 +125,7 @@ export interface ShortcutsConfig {
openControllerSelect?: string | null;
openControllerDebug?: string | null;
toggleSubtitleSidebar?: string | null;
toggleNotificationHistory?: string | null;
}
export interface Config {
+3 -1
View File
@@ -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>;
+1
View File
@@ -13,6 +13,7 @@ export type SessionActionId =
| 'mineSentenceMultiple'
| 'toggleSecondarySub'
| 'toggleSubtitleSidebar'
| 'toggleNotificationHistory'
| 'markAudioCard'
| 'openRuntimeOptions'
| 'openSessionHelp'