fix(notifications): gate overlay delivery on visible overlay; default to

- Default notificationType fallback changed from 'overlay' to 'osd'
- isVisibleOverlayContentReady guards on overlay visible + window ready
- All overlay hide paths dismiss loading OSD notification
- notifyConfiguredStatus falls back to desktop when overlay not ready
- anilist deps builder preserves undefined optional callbacks as undefined
- settingsEnumValues field added to ConfigOptionRegistryEntry
- Drop !important from z-index; lower yomitan popup z-index below notification stack
This commit is contained in:
2026-06-08 01:12:42 -07:00
parent 14cd37d8d7
commit 2b0ce357f1
12 changed files with 114 additions and 26 deletions
+1 -1
View File
@@ -886,7 +886,7 @@ export class AnkiIntegration {
} }
private getNotificationType(): NotificationType { private getNotificationType(): NotificationType {
return this.config.behavior?.notificationType ?? 'overlay'; return this.config.behavior?.notificationType ?? 'osd';
} }
private shouldUseOsdNotifications(): boolean { private shouldUseOsdNotifications(): boolean {
+19
View File
@@ -83,6 +83,25 @@ test('showStatusNotification falls back to system when overlay delivery is unava
assert.deepEqual(calls, ['system:SubMiner:Waiting for card update']); assert.deepEqual(calls, ['system:SubMiner:Waiting for card update']);
}); });
test('showStatusNotification defaults to mpv osd when notification type is unset', () => {
const calls: string[] = [];
showStatusNotification('Card updated', {
getNotificationType: () => undefined,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) => {
calls.push(`overlay:${payload.body}`);
},
showSystemNotification: (title, options) => {
calls.push(`system:${title}:${options.body}`);
},
});
assert.deepEqual(calls, ['osd:Card updated']);
});
test('showStatusNotification does not duplicate system notifications for both', () => { test('showStatusNotification does not duplicate system notifications for both', () => {
const calls: string[] = []; const calls: string[] = [];
+1 -1
View File
@@ -38,7 +38,7 @@ export function showStatusNotification(
message: string, message: string,
context: UiFeedbackNotificationContext, context: UiFeedbackNotificationContext,
): void { ): void {
const type = context.getNotificationType() ?? 'overlay'; const type = context.getNotificationType() ?? 'osd';
if (type === 'none') { if (type === 'none') {
return; return;
+9
View File
@@ -27,7 +27,16 @@ export interface ConfigOptionRegistryEntry {
kind: ConfigValueKind; kind: ConfigValueKind;
defaultValue: unknown; defaultValue: unknown;
description: string; description: string;
/**
* Complete runtime-valid enum options, including legacy file-config values such as
* `osd` and `osd-system` in NOTIFICATION_TYPE_VALUES.
*/
enumValues?: readonly string[]; enumValues?: readonly string[];
/**
* Optional settings UI subset when legacy/runtime-valid enum options should remain
* editable in config files but hidden from new UI choices, for example
* SETTINGS_NOTIFICATION_TYPE_VALUES.
*/
settingsEnumValues?: readonly string[]; settingsEnumValues?: readonly string[];
runtime?: RuntimeOptionRegistryEntry; runtime?: RuntimeOptionRegistryEntry;
} }
+10 -8
View File
@@ -3429,7 +3429,11 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
function isVisibleOverlayContentReady(): boolean { function isVisibleOverlayContentReady(): boolean {
const overlayWindow = overlayManager.getMainWindow(); const overlayWindow = overlayManager.getMainWindow();
return Boolean(overlayWindow && isOverlayWindowContentReady(overlayWindow)); return Boolean(
overlayManager.getVisibleOverlayVisible() &&
overlayWindow &&
isOverlayWindowReadyForNotification(overlayWindow),
);
} }
function getConfiguredStatusNotificationType(): NotificationType { function getConfiguredStatusNotificationType(): NotificationType {
@@ -3448,20 +3452,15 @@ function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
return currentURL !== '' && currentURL !== 'about:blank'; return currentURL !== '' && currentURL !== 'about:blank';
} }
function hasReadyOverlayNotificationWindow(): boolean {
return getOverlayWindows().some((window) => isOverlayWindowReadyForNotification(window));
}
const overlayNotificationDelivery = createOverlayNotificationDelivery({ const overlayNotificationDelivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => hasReadyOverlayNotificationWindow(), hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
send: (payload) => { send: (payload) => {
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload); broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
}, },
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs), scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
}); });
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = null;
null;
function flushQueuedOverlayNotifications(): void { function flushQueuedOverlayNotifications(): void {
overlayNotificationDelivery.flush(); overlayNotificationDelivery.flush();
@@ -7818,6 +7817,7 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
function setVisibleOverlayVisible(visible: boolean): void { function setVisibleOverlayVisible(visible: boolean): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
if (!visible) { if (!visible) {
dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
@@ -7839,6 +7839,7 @@ function toggleVisibleOverlay(): void {
ensureOverlayWindowsReadyForVisibilityActions(); ensureOverlayWindowsReadyForVisibilityActions();
const nextVisible = !overlayManager.getVisibleOverlayVisible(); const nextVisible = !overlayManager.getVisibleOverlayVisible();
if (!nextVisible) { if (!nextVisible) {
dismissOverlayLoadingStatusNotification();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
@@ -7856,6 +7857,7 @@ function toggleVisibleOverlay(): void {
} }
function setOverlayVisible(visible: boolean): void { function setOverlayVisible(visible: boolean): void {
if (!visible) { if (!visible) {
dismissOverlayLoadingStatusNotification();
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
resetVisibleOverlayInputState(); resetVisibleOverlayInputState();
autoplayReadyGate.markCurrentMediaAutoplayReady(); autoplayReadyGate.markCurrentMediaAutoplayReady();
+47 -4
View File
@@ -112,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
assert.ok(actionBlock); assert.ok(actionBlock);
assert.match( assert.match(
actionBlock, actionBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
); );
}); });
@@ -133,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
assert.ok(setOverlayBlock); assert.ok(setOverlayBlock);
assert.match( assert.match(
setVisibleBlock, setVisibleBlock,
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, /if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
toggleBlock, toggleBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/, /if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
); );
assert.match( assert.match(
setOverlayBlock, setOverlayBlock,
/if \(!visible\) \{\s+cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/, /if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
); );
}); });
@@ -418,6 +418,49 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/); assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
}); });
test('manual visible overlay hide dismisses loading OSD', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.ok(setOverlayBlock);
assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
});
test('configured overlay notifications require visible ready overlay window', () => {
const source = readMainSource();
const readinessBlock = source.match(
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const statusBlock = source.match(
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(readinessBlock);
assert.ok(statusBlock);
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
});
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => { test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -23,6 +23,18 @@ test('notify anilist setup main deps builder maps callbacks', () => {
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']); assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
}); });
test('notify anilist setup main deps builder preserves optional notification callbacks', () => {
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {},
})();
assert.equal(deps.getNotificationType, undefined);
assert.equal(deps.showOverlayNotification, undefined);
});
test('consume anilist setup token main deps builder maps callbacks', () => { test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
@@ -18,10 +18,12 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) { export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({ return (): NotifyAnilistSetupMainDeps => ({
getNotificationType: () => deps.getNotificationType?.(), getNotificationType: deps.getNotificationType ? () => deps.getNotificationType?.() : undefined,
hasMpvClient: () => deps.hasMpvClient(), hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message), showMpvOsd: (message: string) => deps.showMpvOsd(message),
showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload), showOverlayNotification: deps.showOverlayNotification
? (payload) => deps.showOverlayNotification?.(payload)
: undefined,
showDesktopNotification: (title: string, options: { body: string }) => showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options), deps.showDesktopNotification(title, options),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
@@ -28,7 +28,7 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
]); ]);
}); });
test('notifyConfiguredStatus queues pre-overlay both status through overlay sender and desktop', () => { test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => {
const calls: string[] = []; const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', { notifyConfiguredStatus('Overlay loading...', {
@@ -43,10 +43,10 @@ test('notifyConfiguredStatus queues pre-overlay both status through overlay send
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['overlay::Overlay loading...', 'desktop:SubMiner:Overlay loading...']); assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
}); });
test('notifyConfiguredStatus queues pre-overlay overlay-only status without osd fallback', () => { test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => {
const calls: string[] = []; const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', { notifyConfiguredStatus('Overlay loading...', {
@@ -61,7 +61,7 @@ test('notifyConfiguredStatus queues pre-overlay overlay-only status without osd
calls.push(`desktop:${title}:${options.body ?? ''}`), calls.push(`desktop:${title}:${options.body ?? ''}`),
}); });
assert.deepEqual(calls, ['overlay::Overlay loading...']); assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
}); });
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => { test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
@@ -50,7 +50,8 @@ export function notifyConfiguredStatus(
} }
if (showOverlay) { if (showOverlay) {
if (deps.showOverlayNotification) { const overlayReady = deps.isOverlayReady?.() ?? true;
if (deps.showOverlayNotification && overlayReady) {
deps.showOverlayNotification({ deps.showOverlayNotification({
id: options.id, id: options.id,
title: options.title ?? 'SubMiner', title: options.title ?? 'SubMiner',
@@ -170,7 +170,7 @@ test('startup OSD reset preserves in-flight tokenization loading for ready updat
}, },
showOverlayNotification: (payload) => { showOverlayNotification: (payload) => {
calls.push( calls.push(
`overlay:${payload.id}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`, `overlay:${payload.id}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
); );
}, },
showDesktopNotification: (title, options) => { showDesktopNotification: (title, options) => {
@@ -183,8 +183,8 @@ test('startup OSD reset preserves in-flight tokenization loading for ready updat
sequencer.markTokenizationReady(); sequencer.markTokenizationReady();
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'overlay:startup-tokenization:Loading subtitle tokenization...:progress:pin', 'overlay:startup-tokenization:Subtitle tokenization:Loading subtitle tokenization...:progress:pin',
'overlay:startup-tokenization:Subtitle tokenization ready:success:auto', 'overlay:startup-tokenization:Subtitle tokenization:Subtitle tokenization ready:success:auto',
'desktop:SubMiner:Subtitle tokenization ready', 'desktop:SubMiner:Subtitle tokenization ready',
]); ]);
}); });
+2 -2
View File
@@ -175,7 +175,7 @@ body:focus-visible,
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
pointer-events: auto; pointer-events: auto;
z-index: 2147483647 !important; z-index: 2147483647;
} }
.overlay-notification-stack.position-top-left { .overlay-notification-stack.position-top-left {
@@ -1900,7 +1900,7 @@ iframe.yomitan-popup,
iframe[id^='yomitan-popup'], iframe[id^='yomitan-popup'],
[data-subminer-yomitan-popup-host='true'] { [data-subminer-yomitan-popup-host='true'] {
pointer-events: auto !important; pointer-events: auto !important;
z-index: 2147483647 !important; z-index: 2147483645;
} }
.kiku-info-text { .kiku-info-text {