Route OSD notifications through overlay with mpv fallback

- Add main/renderer overlay notification pipeline with loading-state support
- Preserve notificationType semantics across osd/system/both/none routing
- Update plugin fallback behavior, docs/config examples, and regression tests
This commit is contained in:
2026-03-16 00:52:13 -07:00
parent e35aac6ee0
commit fbfd688109
30 changed files with 882 additions and 81 deletions

View File

@@ -0,0 +1,51 @@
---
id: TASK-166
title: Replace OSD notifications with overlay notifications and runtime fallback
status: In Progress
assignee: []
created_date: '2026-03-16 00:00'
updated_date: '2026-03-16 00:00'
labels:
- overlay
- notifications
- ux
dependencies: []
references:
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/main.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/src/renderer/renderer.ts
- /home/sudacode/.t3/worktrees/SubMiner/t3code-c5d9c2f7/plugin/subminer/process.lua
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace SubMiner's mpv OSD-first notification flow with overlay-native notifications. The visible overlay becomes the primary in-app notification surface, with fallback to mpv OSD only when the overlay is unavailable, hidden, disabled, or failed to load. Electron/system notifications remain unchanged. Existing notification copy and loading states should be refreshed to fit the richer overlay surface, especially spinner-driven loading states.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Main-process logical OSD notifications render on the overlay when the visible overlay is available and visible.
- [ ] #2 Logical OSD notifications fall back to actual mpv OSD when the overlay is hidden, disabled, unavailable, or fails to load.
- [ ] #3 Notification-type config semantics remain intact: `osd` uses the overlay-or-OSD channel, `system` remains Electron notifications, `both` uses both channels, and `none` suppresses notifications.
- [ ] #4 Overlay notifications support richer presentation for loading/progress states, including a spinner/persistent loading state.
- [ ] #5 Regression coverage verifies routing, renderer behavior, and fallback handling.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Plan:
1. Add failing tests for a pure main-process overlay notification router and a renderer overlay notification controller.
2. Add overlay notification IPC/types plus renderer DOM/controller/CSS for styled toast notifications and spinner handling.
3. Route existing logical OSD callsites through the new overlay-or-OSD fallback seam in main.
4. Trim plugin-side direct OSD usage down to true fallback/error cases.
5. Verify with targeted runtime-compat/core lanes, then escalate if runtime behavior claims remain unproven.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,5 @@
type: changed
area: overlay
- Routed in-app OSD notifications through the visible overlay when available, with fallback to mpv OSD when the overlay is hidden or unavailable.
- Added overlay-native notification styling for loading, success, warning, and error states.

View File

@@ -206,7 +206,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
"overwriteImage": true, // replace existing image, or append "overwriteImage": true, // replace existing image, or append
"mediaInsertMode": "append", // "append" or "prepend" to field content "mediaInsertMode": "append", // "append" or "prepend" to field content
"autoUpdateNewCards": true, // auto-update when new card detected "autoUpdateNewCards": true, // auto-update when new card detected
"notificationType": "osd" // "osd", "system", "both", or "none" "notificationType": "osd" // "osd" prefers overlay notifications and falls back to mpv OSD; "system", "both", or "none"
} }
} }
``` ```

View File

@@ -829,7 +829,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. | | `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification channel for card updates. `"osd"` prefers the visible overlay and falls back to mpv OSD when the overlay is hidden/unavailable (default: `"osd"`). |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |

View File

@@ -348,7 +348,7 @@
"overwriteImage": true, // Overwrite image setting. Values: true | false "overwriteImage": true, // Overwrite image setting. Values: true | false
"mediaInsertMode": "append", // Media insert mode setting. "mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false "highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting. "notificationType": "osd", // Notification channel setting. "osd" prefers overlay notifications and falls back to mpv OSD.
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {

View File

@@ -34,6 +34,17 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false) return options_helper.coerce_bool(raw_pause_until_ready, false)
end end
local function should_prefer_overlay_notifications()
return resolve_visible_overlay_startup()
end
local function show_overlay_fallback_osd(message)
if should_prefer_overlay_notifications() then
return
end
show_osd(message)
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -98,7 +109,7 @@ function M.create(ctx)
end end
disarm_auto_play_ready_gate({ resume_playback = false }) disarm_auto_play_ready_gate({ resume_playback = false })
mp.set_property_native("pause", false) mp.set_property_native("pause", false)
show_osd(AUTO_PLAY_READY_READY_OSD) show_overlay_fallback_osd(AUTO_PLAY_READY_READY_OSD)
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready")) subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end end
@@ -109,11 +120,11 @@ function M.create(ctx)
end end
state.auto_play_ready_gate_armed = true state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true) mp.set_property_native("pause", true)
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
if type(mp.add_periodic_timer) == "function" then if type(mp.add_periodic_timer) == "function" then
state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function()
if state.auto_play_ready_gate_armed then if state.auto_play_ready_gate_armed then
show_osd(AUTO_PLAY_READY_LOADING_OSD) show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD)
end end
end) end)
end end
@@ -298,7 +309,7 @@ function M.create(ctx)
return return
end end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
show_osd("Already running") show_overlay_fallback_osd("Already running")
return return
end end
@@ -332,7 +343,7 @@ function M.create(ctx)
end end
if attempt == 1 and not state.auto_play_ready_gate_armed then if attempt == 1 and not state.auto_play_ready_gate_armed then
show_osd("Starting...") show_overlay_fallback_osd("Starting...")
end end
state.overlay_running = true state.overlay_running = true
@@ -458,7 +469,6 @@ function M.create(ctx)
run_control_command_async("settings", nil, function(ok) run_control_command_async("settings", nil, function(ok)
if ok then if ok then
subminer_log("info", "process", "Options window opened") subminer_log("info", "process", "Options window opened")
show_osd("Options opened")
else else
subminer_log("warn", "process", "Failed to open options") subminer_log("warn", "process", "Failed to open options")
show_osd("Failed to open options") show_osd("Failed to open options")
@@ -474,7 +484,7 @@ function M.create(ctx)
end end
subminer_log("info", "process", "Restarting overlay...") subminer_log("info", "process", "Restarting overlay...")
show_osd("Restarting...") show_overlay_fallback_osd("Restarting...")
run_control_command_async("stop", nil, function() run_control_command_async("stop", nil, function()
state.overlay_running = false state.overlay_running = false
@@ -502,7 +512,7 @@ function M.create(ctx)
) )
show_osd("Restart failed") show_osd("Restart failed")
else else
show_osd("Restarted successfully") show_overlay_fallback_osd("Restarted successfully")
end end
end) end)
end) end)
@@ -516,7 +526,11 @@ function M.create(ctx)
end end
local status = state.overlay_running and "running" or "stopped" local status = state.overlay_running and "running" or "stopped"
if state.overlay_running then
show_overlay_fallback_osd("Status: overlay is " .. status)
else
show_osd("Status: overlay is " .. status) show_osd("Status: overlay is " .. status)
end
subminer_log("info", "process", "Status check: overlay is " .. status) subminer_log("info", "process", "Status check: overlay is " .. status)
end end

View File

@@ -724,12 +724,12 @@ do
"duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events" "duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events"
) )
assert_true( assert_true(
count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2, count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 0,
"duplicate pause-until-ready auto-start should arm tokenization loading gate for each file" "duplicate pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
) )
assert_true( assert_true(
count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2, count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 0,
"duplicate pause-until-ready auto-start should release tokenization gate for each file" "duplicate pause-until-ready auto-start should suppress ready OSD when visible overlay is enabled"
) )
assert_true( assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2, count_property_set(recorded.property_sets, "pause", true) == 2,
@@ -770,16 +770,16 @@ do
"autoplay-ready script message should resume mpv playback" "autoplay-ready script message should resume mpv playback"
) )
assert_true( assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."), not has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."),
"pause-until-ready auto-start should show loading OSD message" "pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled"
) )
assert_true( assert_true(
not has_osd_message(recorded.osd, "SubMiner: Starting..."), not has_osd_message(recorded.osd, "SubMiner: Starting..."),
"pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD" "pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD"
) )
assert_true( assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"), not has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"),
"autoplay-ready should show loaded OSD message" "autoplay-ready should suppress ready OSD when visible overlay is enabled"
) )
assert_true( assert_true(
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,

View File

@@ -361,7 +361,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import type { OverlayHostedModal } from './shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -377,6 +377,11 @@ import { createCharacterDictionaryRuntimeService } from './main/character-dictio
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import {
createConfiguredNotificationHandler,
createShowLogicalOsdHandler,
createShowOverlayNotificationHandler,
} from './main/runtime/overlay-notifications';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
@@ -1120,8 +1125,7 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
{ {
showMpvOsd: (message) => showMpvOsd(message), showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
}, },
); );
const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler();
@@ -1921,9 +1925,7 @@ const {
registerSubminerProtocolClient, registerSubminerProtocolClient,
} = composeAnilistSetupHandlers({ } = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient), showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload),
showMpvOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
consumeTokenDeps: { consumeTokenDeps: {
@@ -3123,8 +3125,10 @@ function openYomitanSettings(): boolean {
logger.warn( logger.warn(
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
); );
showDesktopNotification('SubMiner', { body: message }); showConfiguredNotification('SubMiner', {
showMpvOsd(message); kind: 'warning',
message,
});
return false; return false;
} }
openYomitanSettingsHandler(); openYomitanSettingsHandler();
@@ -3177,7 +3181,7 @@ const {
}, },
}); });
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ const { flushMpvLog, showMpvOsd: showActualMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: { appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH, logPath: DEFAULT_MPV_LOG_PATH,
dirname: (targetPath) => path.dirname(targetPath), dirname: (targetPath) => path.dirname(targetPath),
@@ -3200,6 +3204,21 @@ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
flushPendingMpvLogWrites = () => { flushPendingMpvLogWrites = () => {
void flushMpvLog(); void flushMpvLog();
}; };
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
notificationChannel: IPC_CHANNELS.event.overlayNotification,
});
const showMpvOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => showOverlayNotification(payload),
showMpvOsd: (message) => showActualMpvOsd(message),
});
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
showLogicalOsd: (payload) => showMpvOsd(payload),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
});
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: { cycleSecondarySubModeMainDeps: {

View File

@@ -10,17 +10,14 @@ import {
test('notify anilist setup main deps builder maps callbacks', () => { test('notify anilist setup main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildNotifyAnilistSetupMainDepsHandler({ const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true, showConfiguredNotification: (title, payload) =>
showMpvOsd: (message) => calls.push(`osd:${message}`), calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
showDesktopNotification: (title) => calls.push(`notify:${title}`),
logInfo: (message) => calls.push(`log:${message}`), logInfo: (message) => calls.push(`log:${message}`),
})(); })();
assert.equal(deps.hasMpvClient(), true); deps.showConfiguredNotification('SubMiner', { kind: 'success', message: 'ok' });
deps.showMpvOsd('ok');
deps.showDesktopNotification('SubMiner', { body: 'x' });
deps.logInfo('done'); deps.logInfo('done');
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); assert.deepEqual(calls, ['configured:SubMiner:success:ok', 'log:done']);
}); });
test('consume anilist setup token main deps builder maps callbacks', () => { test('consume anilist setup token main deps builder maps callbacks', () => {

View File

@@ -18,10 +18,7 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({ return (): NotifyAnilistSetupMainDeps => ({
hasMpvClient: () => deps.hasMpvClient(), showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
}); });
} }

View File

@@ -7,16 +7,18 @@ import {
createRegisterSubminerProtocolClientHandler, createRegisterSubminerProtocolClientHandler,
} from './anilist-setup-protocol'; } from './anilist-setup-protocol';
test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => { test('createNotifyAnilistSetupHandler routes AniList setup messages through configured notifications', () => {
const calls: string[] = []; const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({ const notify = createNotifyAnilistSetupHandler({
hasMpvClient: () => true, showConfiguredNotification: (title, payload) =>
showMpvOsd: (message) => calls.push(`osd:${message}`), calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
showDesktopNotification: () => calls.push('desktop'),
logInfo: () => calls.push('log'), logInfo: () => calls.push('log'),
}); });
notify('AniList login success'); notify('AniList login success');
assert.deepEqual(calls, ['osd:AniList login success']); assert.deepEqual(calls, [
'configured:SubMiner AniList:success:AniList login success',
'log',
]);
}); });
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => { test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {

View File

@@ -29,18 +29,28 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
}); });
} }
import type { OverlayNotificationPayload } from '../../types';
function resolveAnilistNotificationKind(message: string): OverlayNotificationPayload['kind'] {
const normalized = message.toLowerCase();
if (normalized.includes('failed') || normalized.includes('error')) {
return 'error';
}
if (normalized.includes('success')) {
return 'success';
}
return 'info';
}
export function createNotifyAnilistSetupHandler(deps: { export function createNotifyAnilistSetupHandler(deps: {
hasMpvClient: () => boolean; showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
showMpvOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}) { }) {
return (message: string): void => { return (message: string): void => {
if (deps.hasMpvClient()) { deps.showConfiguredNotification('SubMiner AniList', {
deps.showMpvOsd(message); kind: resolveAnilistNotificationKind(message),
return; message,
} });
deps.showDesktopNotification('SubMiner AniList', { body: message });
deps.logInfo(`[AniList setup] ${message}`); deps.logInfo(`[AniList setup] ${message}`);
}; };
} }

View File

@@ -60,7 +60,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
]); ]);
}); });
test('auto sync notifications never send desktop notifications', () => { test('auto sync notifications send desktop notifications when the type includes system', () => {
const calls: string[] = []; const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
@@ -88,5 +88,27 @@ test('auto sync notifications never send desktop notifications', () => {
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']); assert.deepEqual(calls, [
'osd:syncing',
'desktop:SubMiner:syncing',
'osd:importing',
'desktop:SubMiner:importing',
'osd:ready',
'desktop:SubMiner:ready',
'osd:failed',
'desktop:SubMiner:failed',
]);
});
test('auto sync notifications respect system-only mode', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'system',
showOsd: (message) => calls.push(`osd:${message}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:syncing']);
}); });

View File

@@ -1,5 +1,6 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync'; import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer'; import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import { shouldShowDesktopNotification, shouldShowLogicalOsd } from './overlay-notifications';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent; export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
@@ -12,23 +13,23 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
}; };
} }
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
}
export function notifyCharacterDictionaryAutoSyncStatus( export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent, event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps, deps: CharacterDictionaryAutoSyncNotificationDeps,
): void { ): void {
const type = deps.getNotificationType(); const type = deps.getNotificationType();
if (shouldShowOsd(type)) { if (shouldShowLogicalOsd(type)) {
if (deps.startupOsdSequencer) { if (deps.startupOsdSequencer) {
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase, phase: event.phase,
message: event.message, message: event.message,
}); });
return; } else {
}
deps.showOsd(event.message); deps.showOsd(event.message);
} }
}
if (shouldShowDesktopNotification(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
} }

View File

@@ -5,9 +5,7 @@ import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => { test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const composed = composeAnilistSetupHandlers({ const composed = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => false, showConfiguredNotification: () => {},
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {}, logInfo: () => {},
}, },
consumeTokenDeps: { consumeTokenDeps: {

View File

@@ -70,12 +70,12 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
const calls: string[] = []; const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({ const handleMessage = createConfigHotReloadMessageHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`), showConfiguredNotification: (title, payload) =>
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
}); });
handleMessage('Config reload failed'); handleMessage('Config reload failed');
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']); assert.deepEqual(calls, ['configured:SubMiner:warning:Config reload failed']);
}); });
test('buildRestartRequiredConfigMessage formats changed fields', () => { test('buildRestartRequiredConfigMessage formats changed fields', () => {

View File

@@ -1,7 +1,12 @@
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { resolveKeybindings } from '../../core/utils/keybindings'; import { resolveKeybindings } from '../../core/utils/keybindings';
import { DEFAULT_KEYBINDINGS } from '../../config'; import { DEFAULT_KEYBINDINGS } from '../../config';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; import type {
ConfigHotReloadPayload,
OverlayNotificationPayload,
ResolvedConfig,
SecondarySubMode,
} from '../../types';
type ConfigHotReloadAppliedDeps = { type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
@@ -14,8 +19,7 @@ type ConfigHotReloadAppliedDeps = {
}; };
type ConfigHotReloadMessageDeps = { type ConfigHotReloadMessageDeps = {
showMpvOsd: (message: string) => void; showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
}; };
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
@@ -66,8 +70,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) { export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => { return (message: string): void => {
deps.showMpvOsd(message); deps.showConfiguredNotification('SubMiner', {
deps.showDesktopNotification('SubMiner', { body: message }); kind: 'warning',
message,
});
}; };
} }

View File

@@ -75,13 +75,12 @@ test('watch config path main deps builder maps filesystem callbacks', () => {
test('config hot reload message main deps builder maps notifications', () => { test('config hot reload message main deps builder maps notifications', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConfigHotReloadMessageMainDepsHandler({ const deps = createBuildConfigHotReloadMessageMainDepsHandler({
showMpvOsd: (message) => calls.push(`osd:${message}`), showConfiguredNotification: (title, payload) =>
showDesktopNotification: (title) => calls.push(`notify:${title}`), calls.push(`configured:${title}:${payload.kind}:${payload.message}`),
})(); })();
deps.showMpvOsd('updated'); deps.showConfiguredNotification('SubMiner', { kind: 'warning', message: 'updated' });
deps.showDesktopNotification('SubMiner', { body: 'updated' }); assert.deepEqual(calls, ['configured:SubMiner:warning:updated']);
assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']);
}); });
test('config hot reload applied main deps builder maps callbacks', () => { test('config hot reload applied main deps builder maps callbacks', () => {

View File

@@ -54,9 +54,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps, deps: ConfigHotReloadMessageMainDeps,
) { ) {
return (): ConfigHotReloadMessageMainDeps => ({ return (): ConfigHotReloadMessageMainDeps => ({
showMpvOsd: (message: string) => deps.showMpvOsd(message), showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
}); });
} }

View File

@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayNotificationPayload } from '../../types';
import {
createConfiguredNotificationHandler,
createShowOverlayNotificationHandler,
createShowLogicalOsdHandler,
} from './overlay-notifications.js';
test('logical OSD prefers overlay notifications when available', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'success',
message: 'Subtitle annotations loaded',
});
assert.equal(result, 'overlay');
assert.deepEqual(calls, ['overlay:success:Subtitle annotations loaded']);
});
test('logical OSD normalizes spinner-frame loading messages for overlay notifications', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.kind}:${payload.message}`);
return true;
},
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
showLogicalOsd('Loading subtitle annotations |');
assert.deepEqual(calls, ['overlay:loading:Loading subtitle annotations']);
});
test('logical OSD falls back to mpv OSD when overlay notifications are unavailable', () => {
const calls: string[] = [];
const showLogicalOsd = createShowLogicalOsdHandler({
showOverlayNotification: () => false,
showMpvOsd: (message) => {
calls.push(`osd:${message}`);
},
});
const result = showLogicalOsd({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(result, 'osd');
assert.deepEqual(calls, ['osd:Loading subtitle annotations']);
});
test('overlay notifications send to the visible overlay when it is enabled and visible', () => {
const calls: string[] = [];
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => false,
getURL: () => 'file:///overlay.html',
once: () => undefined,
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'success',
message: 'Overlay ready',
});
assert.equal(shown, true);
assert.deepEqual(calls, ['overlay:notification:success:Overlay ready']);
});
test('overlay notifications return false when the visible overlay is hidden', () => {
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => false,
getMainWindow: () => null,
notificationChannel: 'overlay:notification',
});
assert.equal(
showOverlayNotification({
kind: 'info',
message: 'Hidden overlay fallback',
}),
false,
);
});
test('overlay notifications queue until renderer load finishes when window is visible but still loading', () => {
const calls: string[] = [];
let didFinishLoad: (() => void) | null = null;
const showOverlayNotification = createShowOverlayNotificationHandler({
isOverlayRuntimeInitialized: () => true,
getVisibleOverlayVisible: () => true,
getMainWindow: () =>
({
isDestroyed: () => false,
isVisible: () => true,
webContents: {
isLoading: () => true,
getURL: () => 'about:blank',
once: (_event: string, listener: () => void) => {
didFinishLoad = listener;
},
send: (channel: string, payload: OverlayNotificationPayload) => {
calls.push(`${channel}:${payload.kind}:${payload.message}`);
},
},
}) as never,
notificationChannel: 'overlay:notification',
});
const shown = showOverlayNotification({
kind: 'loading',
message: 'Loading subtitle annotations',
});
assert.equal(shown, true);
assert.deepEqual(calls, []);
if (didFinishLoad === null) {
throw new Error('expected did-finish-load listener');
}
const runDidFinishLoad: () => void = didFinishLoad;
runDidFinishLoad();
assert.deepEqual(calls, ['overlay:notification:loading:Loading subtitle annotations']);
});
test('configured notifications treat osd as the overlay-or-fallback channel', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'osd',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'info',
message: 'Config reload failed',
});
assert.deepEqual(calls, ['logical:info:Config reload failed']);
});
test('configured notifications send both logical OSD and desktop notifications for both', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'both',
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body}`);
},
});
showConfiguredNotification('SubMiner', {
kind: 'warning',
message: 'Restart required',
});
assert.deepEqual(calls, [
'logical:warning:Restart required',
'desktop:SubMiner:Restart required',
]);
});
test('configured notifications suppress all channels for none', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => 'none',
showLogicalOsd: () => {
calls.push('logical');
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
showConfiguredNotification('SubMiner', {
kind: 'error',
message: 'Overlay failed to start',
});
assert.deepEqual(calls, []);
});
test('configured notifications default missing types to logical OSD only', () => {
const calls: string[] = [];
const showConfiguredNotification = createConfiguredNotificationHandler({
getNotificationType: () => undefined,
showLogicalOsd: (payload) => {
calls.push(`logical:${payload.kind}:${payload.message}`);
return 'overlay';
},
showDesktopNotification: () => {
calls.push('desktop');
},
});
const payload: OverlayNotificationPayload = {
kind: 'success',
message: 'Card updated',
};
showConfiguredNotification('SubMiner', payload);
assert.deepEqual(calls, ['logical:success:Card updated']);
});

View File

@@ -0,0 +1,115 @@
import type { OverlayNotificationPayload } from '../../types';
export type NotificationType = 'osd' | 'system' | 'both' | 'none' | undefined;
function normalizeNotificationPayload(
payload: string | OverlayNotificationPayload,
): OverlayNotificationPayload {
if (typeof payload === 'string') {
const trimmed = payload.trim();
const spinnerNormalized = trimmed.replace(/\s+[|/\\-]$/, '');
if (
trimmed.startsWith('Loading subtitle annotations') ||
trimmed === 'Overlay loading...' ||
trimmed === 'Starting...' ||
trimmed === 'Restarting...'
) {
return {
kind: 'loading',
message: spinnerNormalized,
};
}
return {
kind: 'info',
message: payload,
};
}
return payload;
}
export function createShowLogicalOsdHandler(deps: {
showOverlayNotification: (payload: OverlayNotificationPayload) => boolean;
showMpvOsd: (message: string) => void;
}) {
return (payload: string | OverlayNotificationPayload): 'overlay' | 'osd' => {
const normalized = normalizeNotificationPayload(payload);
if (deps.showOverlayNotification(normalized)) {
return 'overlay';
}
deps.showMpvOsd(normalized.message);
return 'osd';
};
}
export function createShowOverlayNotificationHandler(deps: {
isOverlayRuntimeInitialized: () => boolean;
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => {
isDestroyed: () => boolean;
isVisible: () => boolean;
webContents: {
isLoading: () => boolean;
getURL: () => string;
once: (event: 'did-finish-load', listener: () => void) => void;
send: (channel: string, payload: OverlayNotificationPayload) => void;
};
} | null;
notificationChannel: string;
}) {
return (payload: OverlayNotificationPayload): boolean => {
if (!deps.isOverlayRuntimeInitialized()) {
return false;
}
if (!deps.getVisibleOverlayVisible()) {
return false;
}
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
const send = (): void => {
mainWindow.webContents.send(deps.notificationChannel, payload);
};
if (!mainWindow.webContents.isLoading() && mainWindow.webContents.getURL() !== 'about:blank') {
send();
return true;
}
mainWindow.webContents.once('did-finish-load', () => {
if (!mainWindow.isDestroyed() && mainWindow.isVisible()) {
send();
}
});
return true;
};
}
export function shouldShowLogicalOsd(type: NotificationType): boolean {
return type === undefined || type === 'osd' || type === 'both';
}
export function shouldShowDesktopNotification(type: NotificationType): boolean {
return type === 'system' || type === 'both';
}
export function createConfiguredNotificationHandler(deps: {
getNotificationType: () => NotificationType;
showLogicalOsd: (payload: OverlayNotificationPayload) => 'overlay' | 'osd';
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
}) {
return (title: string, payload: string | OverlayNotificationPayload): void => {
const normalized = normalizeNotificationPayload(payload);
const notificationType = deps.getNotificationType();
if (shouldShowLogicalOsd(notificationType)) {
deps.showLogicalOsd(normalized);
}
if (shouldShowDesktopNotification(notificationType)) {
deps.showDesktopNotification(title, { body: normalized.message });
}
};
}

View File

@@ -51,6 +51,7 @@ import type {
ControllerConfigUpdate, ControllerConfigUpdate,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
OverlayNotificationPayload,
} from './types'; } from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts'; import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -136,6 +137,10 @@ const onKikuFieldGroupingRequestEvent =
IPC_CHANNELS.event.kikuFieldGroupingRequest, IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData, (payload) => payload as KikuFieldGroupingRequestData,
); );
const onOverlayNotificationEvent = createQueuedIpcListenerWithPayload<OverlayNotificationPayload>(
IPC_CHANNELS.event.overlayNotification,
(payload) => payload as OverlayNotificationPayload,
);
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer, getOverlayLayer: () => overlayLayer,
@@ -318,6 +323,7 @@ const electronAPI: ElectronAPI = {
}, },
); );
}, },
onOverlayNotification: onOverlayNotificationEvent,
}; };
contextBridge.exposeInMainWorld('electronAPI', electronAPI); contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -42,6 +42,18 @@
role="status" role="status"
aria-live="polite" aria-live="polite"
></div> ></div>
<div
id="overlayNotificationToast"
class="overlay-notification-toast hidden"
role="status"
aria-live="polite"
>
<div id="overlayNotificationSpinner" class="overlay-notification-spinner hidden"></div>
<div class="overlay-notification-copy">
<div id="overlayNotificationTitle" class="overlay-notification-title"></div>
<div id="overlayNotificationMessage" class="overlay-notification-message"></div>
</div>
</div>
<div id="secondarySubContainer" class="secondary-sub-hidden"> <div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div> <div id="secondarySubRoot"></div>
</div> </div>

View File

@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayNotificationsController } from './overlay-notifications.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
test('overlay notifications show loading state with spinner and no auto-hide timer', () => {
const toast = {
classList: createClassList(['hidden']),
dataset: {} as Record<string, string>,
};
const title = { textContent: '' };
const message = { textContent: '' };
const spinner = { classList: createClassList(['hidden']) };
let scheduled = false;
const controller = createOverlayNotificationsController(
{
overlayNotificationToast: toast,
overlayNotificationTitle: title,
overlayNotificationMessage: message,
overlayNotificationSpinner: spinner,
} as never,
{
setTimeout: () => {
scheduled = true;
return 1 as never;
},
clearTimeout: () => {},
},
);
controller.show({
kind: 'loading',
title: 'SubMiner',
message: 'Loading subtitle annotations',
});
assert.equal(toast.classList.contains('hidden'), false);
assert.equal(spinner.classList.contains('hidden'), false);
assert.equal(title.textContent, 'SubMiner');
assert.equal(message.textContent, 'Loading subtitle annotations');
assert.equal(toast.dataset.kind, 'loading');
assert.equal(scheduled, false);
});
test('overlay notifications auto-hide non-loading messages and clear loading styling', () => {
let nextTimerId = 1;
const scheduled = new Map<number, () => void>();
const toast = {
classList: createClassList(['hidden']),
dataset: {} as Record<string, string>,
};
const title = { textContent: '' };
const message = { textContent: '' };
const spinner = { classList: createClassList(['hidden']) };
const controller = createOverlayNotificationsController(
{
overlayNotificationToast: toast,
overlayNotificationTitle: title,
overlayNotificationMessage: message,
overlayNotificationSpinner: spinner,
} as never,
{
durationMs: 1200,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
scheduled.set(id, callback);
return id as never;
},
clearTimeout: (id) => {
scheduled.delete(id as never as number);
},
},
);
controller.show({
kind: 'loading',
title: 'SubMiner',
message: 'Loading subtitle annotations',
});
controller.show({
kind: 'success',
title: 'SubMiner',
message: 'Subtitle annotations loaded',
});
assert.equal(spinner.classList.contains('hidden'), true);
assert.equal(toast.dataset.kind, 'success');
assert.equal(message.textContent, 'Subtitle annotations loaded');
assert.equal(scheduled.size, 1);
const [hide] = scheduled.values();
hide?.();
assert.equal(toast.classList.contains('hidden'), true);
assert.equal(title.textContent, '');
assert.equal(message.textContent, '');
});

View File

@@ -0,0 +1,82 @@
import type { OverlayNotificationPayload } from '../types';
type OverlayNotificationsDom = {
overlayNotificationToast: {
classList: {
add: (...tokens: string[]) => void;
remove: (...tokens: string[]) => void;
contains?: (token: string) => boolean;
};
dataset: {
kind?: string;
};
};
overlayNotificationTitle: {
textContent: string;
};
overlayNotificationMessage: {
textContent: string;
};
overlayNotificationSpinner: {
classList: {
add: (...tokens: string[]) => void;
remove: (...tokens: string[]) => void;
};
};
};
type OverlayNotificationsTimerDeps = {
durationMs?: number;
setTimeout?: (callback: () => void, delayMs: number) => number | ReturnType<typeof setTimeout>;
clearTimeout?: (timeout: number | ReturnType<typeof setTimeout>) => void;
};
export function createOverlayNotificationsController(
dom: OverlayNotificationsDom,
deps: OverlayNotificationsTimerDeps = {},
) {
let hideTimer: number | ReturnType<typeof setTimeout> | null = null;
const durationMs = deps.durationMs ?? 2200;
const setTimeoutHandler = deps.setTimeout ?? setTimeout;
const clearTimeoutHandler = deps.clearTimeout ?? clearTimeout;
const clearHideTimer = (): void => {
if (hideTimer === null) {
return;
}
clearTimeoutHandler(hideTimer);
hideTimer = null;
};
const hide = (): void => {
clearHideTimer();
dom.overlayNotificationToast.classList.add('hidden');
dom.overlayNotificationSpinner.classList.add('hidden');
dom.overlayNotificationToast.dataset.kind = '';
dom.overlayNotificationTitle.textContent = '';
dom.overlayNotificationMessage.textContent = '';
};
const show = (payload: OverlayNotificationPayload): void => {
clearHideTimer();
dom.overlayNotificationToast.classList.remove('hidden');
dom.overlayNotificationToast.dataset.kind = payload.kind;
dom.overlayNotificationTitle.textContent = payload.title ?? '';
dom.overlayNotificationMessage.textContent = payload.message;
if (payload.kind === 'loading') {
dom.overlayNotificationSpinner.classList.remove('hidden');
return;
}
dom.overlayNotificationSpinner.classList.add('hidden');
hideTimer = setTimeoutHandler(() => {
hide();
}, payload.durationMs ?? durationMs);
};
return {
show,
hide,
};
}

View File

@@ -29,6 +29,7 @@ import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createGamepadController } from './handlers/gamepad-controller.js'; import { createGamepadController } from './handlers/gamepad-controller.js';
import { createMouseHandlers } from './handlers/mouse.js'; import { createMouseHandlers } from './handlers/mouse.js';
import { createControllerStatusIndicator } from './controller-status-indicator.js'; import { createControllerStatusIndicator } from './controller-status-indicator.js';
import { createOverlayNotificationsController } from './overlay-notifications.js';
import { createControllerDebugModal } from './modals/controller-debug.js'; import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js'; import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
@@ -110,6 +111,7 @@ const controllerDebugModal = createControllerDebugModal(ctx, {
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom); const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const overlayNotifications = createOverlayNotificationsController(ctx.dom);
const sessionHelpModal = createSessionHelpModal(ctx, { const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
@@ -431,6 +433,12 @@ function registerKeyboardCommandHandlers(): void {
keyboardHandlers.handleLookupWindowToggleRequested(); keyboardHandlers.handleLookupWindowToggleRequested();
}); });
}); });
window.electronAPI.onOverlayNotification((payload) => {
runGuarded('overlay-notification', () => {
overlayNotifications.show(payload);
});
});
} }
function runGuarded(action: string, fn: () => void): void { function runGuarded(action: string, fn: () => void): void {

View File

@@ -108,6 +108,93 @@ body {
transform: translateY(0); transform: translateY(0);
} }
.overlay-notification-toast {
position: absolute;
top: 18px;
right: 18px;
display: flex;
align-items: center;
gap: 12px;
max-width: min(460px, calc(100vw - 36px));
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 46%),
linear-gradient(140deg, rgba(12, 18, 26, 0.96), rgba(20, 31, 46, 0.95));
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.32);
color: rgba(245, 249, 255, 0.98);
pointer-events: none;
opacity: 0;
transform: translateY(-8px) scale(0.985);
transition:
opacity 180ms ease,
transform 180ms ease;
z-index: 1320;
}
.overlay-notification-toast[data-kind='success'] {
border-color: rgba(139, 213, 202, 0.35);
}
.overlay-notification-toast[data-kind='warning'] {
border-color: rgba(245, 169, 127, 0.45);
}
.overlay-notification-toast[data-kind='error'] {
border-color: rgba(237, 135, 150, 0.5);
}
.overlay-notification-toast[data-kind='loading'] {
border-color: rgba(138, 173, 244, 0.4);
}
.overlay-notification-toast:not(.hidden) {
opacity: 1;
transform: translateY(0) scale(1);
}
.overlay-notification-spinner {
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: rgba(198, 224, 255, 0.95);
flex: 0 0 auto;
animation: overlay-notification-spin 720ms linear infinite;
}
.overlay-notification-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.overlay-notification-title {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(184, 211, 255, 0.74);
}
.overlay-notification-message {
font-size: 14px;
line-height: 1.35;
font-weight: 700;
color: rgba(245, 249, 255, 0.98);
}
@keyframes overlay-notification-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.modal { .modal {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -4,6 +4,10 @@ export type RendererDom = {
overlay: HTMLElement; overlay: HTMLElement;
controllerStatusToast: HTMLDivElement; controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement; overlayErrorToast: HTMLDivElement;
overlayNotificationToast: HTMLDivElement;
overlayNotificationTitle: HTMLDivElement;
overlayNotificationMessage: HTMLDivElement;
overlayNotificationSpinner: HTMLDivElement;
secondarySubContainer: HTMLElement; secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement; secondarySubRoot: HTMLElement;
@@ -99,6 +103,10 @@ export function resolveRendererDom(): RendererDom {
overlay: getRequiredElement<HTMLElement>('overlay'), overlay: getRequiredElement<HTMLElement>('overlay'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'), controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'), overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
overlayNotificationToast: getRequiredElement<HTMLDivElement>('overlayNotificationToast'),
overlayNotificationTitle: getRequiredElement<HTMLDivElement>('overlayNotificationTitle'),
overlayNotificationMessage: getRequiredElement<HTMLDivElement>('overlayNotificationMessage'),
overlayNotificationSpinner: getRequiredElement<HTMLDivElement>('overlayNotificationSpinner'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'), secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'), secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),

View File

@@ -77,6 +77,7 @@ export const IPC_CHANNELS = {
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
}, },
} as const; } as const;

View File

@@ -1015,6 +1015,15 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
} }
export type OverlayNotificationKind = 'info' | 'success' | 'warning' | 'error' | 'loading';
export interface OverlayNotificationPayload {
kind: OverlayNotificationKind;
title?: string;
message: string;
durationMs?: number;
}
export type ResolvedControllerConfig = ResolvedConfig['controller']; export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface SubtitleHoverTokenPayload { export interface SubtitleHoverTokenPayload {
@@ -1097,6 +1106,7 @@ export interface ElectronAPI {
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
onOverlayNotification: (callback: (payload: OverlayNotificationPayload) => void) => void;
} }
declare global { declare global {