diff --git a/backlog/tasks/task-166 - Replace-OSD-notifications-with-overlay-notifications-and-runtime-fallback.md b/backlog/tasks/task-166 - Replace-OSD-notifications-with-overlay-notifications-and-runtime-fallback.md new file mode 100644 index 0000000..ae8c798 --- /dev/null +++ b/backlog/tasks/task-166 - Replace-OSD-notifications-with-overlay-notifications-and-runtime-fallback.md @@ -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 + + + +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. + + + +## Acceptance Criteria + + + +- [ ] #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. + + + +## Implementation Notes + + + +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. + + diff --git a/changes/overlay-notification-routing.md b/changes/overlay-notification-routing.md new file mode 100644 index 0000000..0853112 --- /dev/null +++ b/changes/overlay-notification-routing.md @@ -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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index a17f8ff..124b018 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -206,7 +206,7 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) "overwriteImage": true, // replace existing image, or append "mediaInsertMode": "append", // "append" or "prepend" to field content "autoUpdateNewCards": true, // auto-update when new card detected - "notificationType": "osd" // "osd", "system", "both", or "none" + "notificationType": "osd" // "osd" prefers overlay notifications and falls back to mpv OSD; "system", "both", or "none" } } ``` diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 6a4de49..770f1eb 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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.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. | -| `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`) | | `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`. | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index ec1000d..9d6ed8e 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -348,7 +348,7 @@ "overwriteImage": true, // Overwrite image setting. Values: true | false "mediaInsertMode": "append", // Media insert mode setting. "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 }, // Behavior setting. "nPlusOne": { diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 3d042ac..9fa1803 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -34,6 +34,17 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) 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) if type(path) ~= "string" then return nil @@ -98,7 +109,7 @@ function M.create(ctx) end disarm_auto_play_ready_gate({ resume_playback = 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")) end @@ -109,11 +120,11 @@ function M.create(ctx) end state.auto_play_ready_gate_armed = true mp.set_property_native("pause", true) - show_osd(AUTO_PLAY_READY_LOADING_OSD) + show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD) if type(mp.add_periodic_timer) == "function" then state.auto_play_ready_osd_timer = mp.add_periodic_timer(2.5, function() if state.auto_play_ready_gate_armed then - show_osd(AUTO_PLAY_READY_LOADING_OSD) + show_overlay_fallback_osd(AUTO_PLAY_READY_LOADING_OSD) end end) end @@ -298,7 +309,7 @@ function M.create(ctx) return end subminer_log("info", "process", "Overlay already running") - show_osd("Already running") + show_overlay_fallback_osd("Already running") return end @@ -332,7 +343,7 @@ function M.create(ctx) end if attempt == 1 and not state.auto_play_ready_gate_armed then - show_osd("Starting...") + show_overlay_fallback_osd("Starting...") end state.overlay_running = true @@ -458,7 +469,6 @@ function M.create(ctx) run_control_command_async("settings", nil, function(ok) if ok then subminer_log("info", "process", "Options window opened") - show_osd("Options opened") else subminer_log("warn", "process", "Failed to open options") show_osd("Failed to open options") @@ -474,7 +484,7 @@ function M.create(ctx) end subminer_log("info", "process", "Restarting overlay...") - show_osd("Restarting...") + show_overlay_fallback_osd("Restarting...") run_control_command_async("stop", nil, function() state.overlay_running = false @@ -502,7 +512,7 @@ function M.create(ctx) ) show_osd("Restart failed") else - show_osd("Restarted successfully") + show_overlay_fallback_osd("Restarted successfully") end end) end) @@ -516,7 +526,11 @@ function M.create(ctx) end local status = state.overlay_running and "running" or "stopped" - show_osd("Status: overlay is " .. status) + if state.overlay_running then + show_overlay_fallback_osd("Status: overlay is " .. status) + else + show_osd("Status: overlay is " .. status) + end subminer_log("info", "process", "Status check: overlay is " .. status) end diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 4471d25..0ec3c42 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -724,12 +724,12 @@ do "duplicate pause-until-ready auto-start should re-assert visible overlay on both start and ready events" ) assert_true( - count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 2, - "duplicate pause-until-ready auto-start should arm tokenization loading gate for each file" + count_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization...") == 0, + "duplicate pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled" ) assert_true( - count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 2, - "duplicate pause-until-ready auto-start should release tokenization gate for each file" + count_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready") == 0, + "duplicate pause-until-ready auto-start should suppress ready OSD when visible overlay is enabled" ) assert_true( count_property_set(recorded.property_sets, "pause", true) == 2, @@ -770,16 +770,16 @@ do "autoplay-ready script message should resume mpv playback" ) assert_true( - has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."), - "pause-until-ready auto-start should show loading OSD message" + not has_osd_message(recorded.osd, "SubMiner: Loading subtitle tokenization..."), + "pause-until-ready auto-start should suppress loading OSD when visible overlay is enabled" ) assert_true( not has_osd_message(recorded.osd, "SubMiner: Starting..."), "pause-until-ready auto-start should avoid replacing loading OSD with generic starting OSD" ) assert_true( - has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"), - "autoplay-ready should show loaded OSD message" + not has_osd_message(recorded.osd, "SubMiner: Subtitle tokenization ready"), + "autoplay-ready should suppress ready OSD when visible overlay is enabled" ) assert_true( count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2, diff --git a/src/main.ts b/src/main.ts index c8a25f1..6cfb472 100644 --- a/src/main.ts +++ b/src/main.ts @@ -361,7 +361,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-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 { createFrequencyDictionaryRuntimeService, @@ -377,6 +377,11 @@ import { createCharacterDictionaryRuntimeService } from './main/character-dictio import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; 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 { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; @@ -1120,8 +1125,7 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => { const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler( { - showMpvOsd: (message) => showMpvOsd(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), + showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload), }, ); const configHotReloadMessageMainDeps = buildConfigHotReloadMessageMainDepsHandler(); @@ -1921,9 +1925,7 @@ const { registerSubminerProtocolClient, } = composeAnilistSetupHandlers({ notifyDeps: { - hasMpvClient: () => Boolean(appState.mpvClient), - showMpvOsd: (message) => showMpvOsd(message), - showDesktopNotification: (title, options) => showDesktopNotification(title, options), + showConfiguredNotification: (title, payload) => showConfiguredNotification(title, payload), logInfo: (message) => logger.info(message), }, consumeTokenDeps: { @@ -3123,8 +3125,10 @@ function openYomitanSettings(): boolean { logger.warn( 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', ); - showDesktopNotification('SubMiner', { body: message }); - showMpvOsd(message); + showConfiguredNotification('SubMiner', { + kind: 'warning', + message, + }); return false; } openYomitanSettingsHandler(); @@ -3177,7 +3181,7 @@ const { }, }); -const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ +const { flushMpvLog, showMpvOsd: showActualMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { logPath: DEFAULT_MPV_LOG_PATH, dirname: (targetPath) => path.dirname(targetPath), @@ -3200,6 +3204,21 @@ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ flushPendingMpvLogWrites = () => { 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({ cycleSecondarySubModeMainDeps: { diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts index 60106f6..f61cb55 100644 --- a/src/main/runtime/anilist-setup-protocol-main-deps.test.ts +++ b/src/main/runtime/anilist-setup-protocol-main-deps.test.ts @@ -10,17 +10,14 @@ import { test('notify anilist setup main deps builder maps callbacks', () => { const calls: string[] = []; const deps = createBuildNotifyAnilistSetupMainDepsHandler({ - hasMpvClient: () => true, - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: (title) => calls.push(`notify:${title}`), + showConfiguredNotification: (title, payload) => + calls.push(`configured:${title}:${payload.kind}:${payload.message}`), logInfo: (message) => calls.push(`log:${message}`), })(); - assert.equal(deps.hasMpvClient(), true); - deps.showMpvOsd('ok'); - deps.showDesktopNotification('SubMiner', { body: 'x' }); + deps.showConfiguredNotification('SubMiner', { kind: 'success', message: 'ok' }); 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', () => { diff --git a/src/main/runtime/anilist-setup-protocol-main-deps.ts b/src/main/runtime/anilist-setup-protocol-main-deps.ts index f32ddd4..fdf99f8 100644 --- a/src/main/runtime/anilist-setup-protocol-main-deps.ts +++ b/src/main/runtime/anilist-setup-protocol-main-deps.ts @@ -18,10 +18,7 @@ type RegisterSubminerProtocolClientMainDeps = Parameters< export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { return (): NotifyAnilistSetupMainDeps => ({ - hasMpvClient: () => deps.hasMpvClient(), - showMpvOsd: (message: string) => deps.showMpvOsd(message), - showDesktopNotification: (title: string, options: { body: string }) => - deps.showDesktopNotification(title, options), + showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload), logInfo: (message: string) => deps.logInfo(message), }); } diff --git a/src/main/runtime/anilist-setup-protocol.test.ts b/src/main/runtime/anilist-setup-protocol.test.ts index a006668..1671bdc 100644 --- a/src/main/runtime/anilist-setup-protocol.test.ts +++ b/src/main/runtime/anilist-setup-protocol.test.ts @@ -7,16 +7,18 @@ import { createRegisterSubminerProtocolClientHandler, } 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 notify = createNotifyAnilistSetupHandler({ - hasMpvClient: () => true, - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: () => calls.push('desktop'), + showConfiguredNotification: (title, payload) => + calls.push(`configured:${title}:${payload.kind}:${payload.message}`), logInfo: () => calls.push('log'), }); 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', () => { diff --git a/src/main/runtime/anilist-setup-protocol.ts b/src/main/runtime/anilist-setup-protocol.ts index 90d75e6..6bf4af8 100644 --- a/src/main/runtime/anilist-setup-protocol.ts +++ b/src/main/runtime/anilist-setup-protocol.ts @@ -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: { - hasMpvClient: () => boolean; - showMpvOsd: (message: string) => void; - showDesktopNotification: (title: string, options: { body: string }) => void; + showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void; logInfo: (message: string) => void; }) { return (message: string): void => { - if (deps.hasMpvClient()) { - deps.showMpvOsd(message); - return; - } - deps.showDesktopNotification('SubMiner AniList', { body: message }); + deps.showConfiguredNotification('SubMiner AniList', { + kind: resolveAnilistNotificationKind(message), + message, + }); deps.logInfo(`[AniList setup] ${message}`); }; } diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index 7608ebc..fa6748b 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -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[] = []; notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), { @@ -88,5 +88,27 @@ test('auto sync notifications never send desktop notifications', () => { calls.push(`desktop:${title}:${options.body ?? ''}`), }); - assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']); + assert.deepEqual(calls, [ + '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']); }); diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.ts index 3b384b4..a9a7f17 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.ts @@ -1,5 +1,6 @@ import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync'; import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer'; +import { shouldShowDesktopNotification, shouldShowLogicalOsd } from './overlay-notifications'; 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( event: CharacterDictionaryAutoSyncNotificationEvent, deps: CharacterDictionaryAutoSyncNotificationDeps, ): void { const type = deps.getNotificationType(); - if (shouldShowOsd(type)) { + if (shouldShowLogicalOsd(type)) { if (deps.startupOsdSequencer) { deps.startupOsdSequencer.notifyCharacterDictionaryStatus({ phase: event.phase, message: event.message, }); - return; + } else { + deps.showOsd(event.message); } - deps.showOsd(event.message); + } + + if (shouldShowDesktopNotification(type)) { + deps.showDesktopNotification('SubMiner', { body: event.message }); } } diff --git a/src/main/runtime/composers/anilist-setup-composer.test.ts b/src/main/runtime/composers/anilist-setup-composer.test.ts index df22dd9..302de66 100644 --- a/src/main/runtime/composers/anilist-setup-composer.test.ts +++ b/src/main/runtime/composers/anilist-setup-composer.test.ts @@ -5,9 +5,7 @@ import { composeAnilistSetupHandlers } from './anilist-setup-composer'; test('composeAnilistSetupHandlers returns callable setup handlers', () => { const composed = composeAnilistSetupHandlers({ notifyDeps: { - hasMpvClient: () => false, - showMpvOsd: () => {}, - showDesktopNotification: () => {}, + showConfiguredNotification: () => {}, logInfo: () => {}, }, consumeTokenDeps: { diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 0a5e228..0e5c9c5 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -70,12 +70,12 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { const calls: string[] = []; const handleMessage = createConfigHotReloadMessageHandler({ - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), + showConfiguredNotification: (title, payload) => + calls.push(`configured:${title}:${payload.kind}:${payload.message}`), }); 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', () => { diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 9458b9a..48af879 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -1,7 +1,12 @@ import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; import { resolveKeybindings } from '../../core/utils/keybindings'; import { DEFAULT_KEYBINDINGS } from '../../config'; -import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; +import type { + ConfigHotReloadPayload, + OverlayNotificationPayload, + ResolvedConfig, + SecondarySubMode, +} from '../../types'; type ConfigHotReloadAppliedDeps = { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; @@ -14,8 +19,7 @@ type ConfigHotReloadAppliedDeps = { }; type ConfigHotReloadMessageDeps = { - showMpvOsd: (message: string) => void; - showDesktopNotification: (title: string, options: { body: string }) => void; + showConfiguredNotification: (title: string, payload: OverlayNotificationPayload) => void; }; export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { @@ -66,8 +70,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) { return (message: string): void => { - deps.showMpvOsd(message); - deps.showDesktopNotification('SubMiner', { body: message }); + deps.showConfiguredNotification('SubMiner', { + kind: 'warning', + message, + }); }; } diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index fc01aa0..d1f35ec 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -75,13 +75,12 @@ test('watch config path main deps builder maps filesystem callbacks', () => { test('config hot reload message main deps builder maps notifications', () => { const calls: string[] = []; const deps = createBuildConfigHotReloadMessageMainDepsHandler({ - showMpvOsd: (message) => calls.push(`osd:${message}`), - showDesktopNotification: (title) => calls.push(`notify:${title}`), + showConfiguredNotification: (title, payload) => + calls.push(`configured:${title}:${payload.kind}:${payload.message}`), })(); - deps.showMpvOsd('updated'); - deps.showDesktopNotification('SubMiner', { body: 'updated' }); - assert.deepEqual(calls, ['osd:updated', 'notify:SubMiner']); + deps.showConfiguredNotification('SubMiner', { kind: 'warning', message: 'updated' }); + assert.deepEqual(calls, ['configured:SubMiner:warning:updated']); }); test('config hot reload applied main deps builder maps callbacks', () => { diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index e93ca69..1c3db2c 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -54,9 +54,7 @@ export function createBuildConfigHotReloadMessageMainDepsHandler( deps: ConfigHotReloadMessageMainDeps, ) { return (): ConfigHotReloadMessageMainDeps => ({ - showMpvOsd: (message: string) => deps.showMpvOsd(message), - showDesktopNotification: (title: string, options: { body: string }) => - deps.showDesktopNotification(title, options), + showConfiguredNotification: (title, payload) => deps.showConfiguredNotification(title, payload), }); } diff --git a/src/main/runtime/overlay-notifications.test.ts b/src/main/runtime/overlay-notifications.test.ts new file mode 100644 index 0000000..42ecaba --- /dev/null +++ b/src/main/runtime/overlay-notifications.test.ts @@ -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']); +}); diff --git a/src/main/runtime/overlay-notifications.ts b/src/main/runtime/overlay-notifications.ts new file mode 100644 index 0000000..d2a165b --- /dev/null +++ b/src/main/runtime/overlay-notifications.ts @@ -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 }); + } + }; +} diff --git a/src/preload.ts b/src/preload.ts index 878b6f8..ef17fe9 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -51,6 +51,7 @@ import type { ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, + OverlayNotificationPayload, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -136,6 +137,10 @@ const onKikuFieldGroupingRequestEvent = IPC_CHANNELS.event.kikuFieldGroupingRequest, (payload) => payload as KikuFieldGroupingRequestData, ); +const onOverlayNotificationEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.overlayNotification, + (payload) => payload as OverlayNotificationPayload, +); const electronAPI: ElectronAPI = { getOverlayLayer: () => overlayLayer, @@ -318,6 +323,7 @@ const electronAPI: ElectronAPI = { }, ); }, + onOverlayNotification: onOverlayNotificationEvent, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/index.html b/src/renderer/index.html index 2eae08d..edbf924 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,6 +42,18 @@ role="status" aria-live="polite" > +
diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts new file mode 100644 index 0000000..9edb7ec --- /dev/null +++ b/src/renderer/overlay-notifications.test.ts @@ -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, + }; + 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 void>(); + const toast = { + classList: createClassList(['hidden']), + dataset: {} as Record, + }; + 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, ''); +}); diff --git a/src/renderer/overlay-notifications.ts b/src/renderer/overlay-notifications.ts new file mode 100644 index 0000000..753ba45 --- /dev/null +++ b/src/renderer/overlay-notifications.ts @@ -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; + clearTimeout?: (timeout: number | ReturnType) => void; +}; + +export function createOverlayNotificationsController( + dom: OverlayNotificationsDom, + deps: OverlayNotificationsTimerDeps = {}, +) { + let hideTimer: number | ReturnType | 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, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 7ba7fb9..970b474 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -29,6 +29,7 @@ import { createKeyboardHandlers } from './handlers/keyboard.js'; import { createGamepadController } from './handlers/gamepad-controller.js'; import { createMouseHandlers } from './handlers/mouse.js'; import { createControllerStatusIndicator } from './controller-status-indicator.js'; +import { createOverlayNotificationsController } from './overlay-notifications.js'; import { createControllerDebugModal } from './modals/controller-debug.js'; import { createControllerSelectModal } from './modals/controller-select.js'; import { createJimakuModal } from './modals/jimaku.js'; @@ -110,6 +111,7 @@ const controllerDebugModal = createControllerDebugModal(ctx, { syncSettingsModalSubtitleSuppression, }); const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom); +const overlayNotifications = createOverlayNotificationsController(ctx.dom); const sessionHelpModal = createSessionHelpModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, @@ -431,6 +433,12 @@ function registerKeyboardCommandHandlers(): void { keyboardHandlers.handleLookupWindowToggleRequested(); }); }); + + window.electronAPI.onOverlayNotification((payload) => { + runGuarded('overlay-notification', () => { + overlayNotifications.show(payload); + }); + }); } function runGuarded(action: string, fn: () => void): void { diff --git a/src/renderer/style.css b/src/renderer/style.css index e495000..0819470 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -108,6 +108,93 @@ body { 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 { position: absolute; inset: 0; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 58025db..0d40989 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -4,6 +4,10 @@ export type RendererDom = { overlay: HTMLElement; controllerStatusToast: HTMLDivElement; overlayErrorToast: HTMLDivElement; + overlayNotificationToast: HTMLDivElement; + overlayNotificationTitle: HTMLDivElement; + overlayNotificationMessage: HTMLDivElement; + overlayNotificationSpinner: HTMLDivElement; secondarySubContainer: HTMLElement; secondarySubRoot: HTMLElement; @@ -99,6 +103,10 @@ export function resolveRendererDom(): RendererDom { overlay: getRequiredElement('overlay'), controllerStatusToast: getRequiredElement('controllerStatusToast'), overlayErrorToast: getRequiredElement('overlayErrorToast'), + overlayNotificationToast: getRequiredElement('overlayNotificationToast'), + overlayNotificationTitle: getRequiredElement('overlayNotificationTitle'), + overlayNotificationMessage: getRequiredElement('overlayNotificationMessage'), + overlayNotificationSpinner: getRequiredElement('overlayNotificationSpinner'), secondarySubContainer: getRequiredElement('secondarySubContainer'), secondarySubRoot: getRequiredElement('secondarySubRoot'), diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 3886855..208e556 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -77,6 +77,7 @@ export const IPC_CHANNELS = { keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', configHotReload: 'config:hot-reload', + overlayNotification: 'overlay:notification', }, } as const; diff --git a/src/types.ts b/src/types.ts index 64cb246..590fa66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1015,6 +1015,15 @@ export interface ConfigHotReloadPayload { 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 interface SubtitleHoverTokenPayload { @@ -1097,6 +1106,7 @@ export interface ElectronAPI { ) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; + onOverlayNotification: (callback: (payload: OverlayNotificationPayload) => void) => void; } declare global {