mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Compare commits
2 Commits
v0.7.0
...
t3code/ove
| Author | SHA1 | Date | |
|---|---|---|---|
|
385959a1bd
|
|||
|
fbfd688109
|
@@ -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 -->
|
||||
5
changes/overlay-notification-routing.md
Normal file
5
changes/overlay-notification-routing.md
Normal 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
src/main.ts
37
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: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
241
src/main/runtime/overlay-notifications.test.ts
Normal file
241
src/main/runtime/overlay-notifications.test.ts
Normal 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']);
|
||||
});
|
||||
115
src/main/runtime/overlay-notifications.ts
Normal file
115
src/main/runtime/overlay-notifications.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<OverlayNotificationPayload>(
|
||||
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);
|
||||
|
||||
@@ -42,6 +42,18 @@
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></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="secondarySubRoot"></div>
|
||||
</div>
|
||||
|
||||
112
src/renderer/overlay-notifications.test.ts
Normal file
112
src/renderer/overlay-notifications.test.ts
Normal 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, '');
|
||||
});
|
||||
82
src/renderer/overlay-notifications.ts
Normal file
82
src/renderer/overlay-notifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLElement>('overlay'),
|
||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
overlayNotificationToast: getRequiredElement<HTMLDivElement>('overlayNotificationToast'),
|
||||
overlayNotificationTitle: getRequiredElement<HTMLDivElement>('overlayNotificationTitle'),
|
||||
overlayNotificationMessage: getRequiredElement<HTMLDivElement>('overlayNotificationMessage'),
|
||||
overlayNotificationSpinner: getRequiredElement<HTMLDivElement>('overlayNotificationSpinner'),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
10
src/types.ts
10
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 {
|
||||
|
||||
Reference in New Issue
Block a user