diff --git a/changes/anki-overlay-update-progress.md b/changes/anki-overlay-update-progress.md new file mode 100644 index 00000000..c51a4910 --- /dev/null +++ b/changes/anki-overlay-update-progress.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept the Anki card update spinner visible until audio and image updates finish. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index b4bf358d..5fae3673 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -11,10 +11,12 @@ import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; type TestOverlayNotificationPayload = { + id?: string; title: string; body?: string; image?: string; variant?: string; + persistent?: boolean; actions?: Array<{ id: string; label: string; noteId?: number }>; }; @@ -1182,6 +1184,82 @@ test('AnkiIntegration embeds generated notification image on overlay mined-card assert.deepEqual(cleanupPaths, [notificationIconPath]); }); +test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => { + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'overlay', + }, + }, + {} as never, + {} as never, + undefined, + undefined, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayNotifications.push(payload); + }, + ); + const updateNotifications = integration as unknown as { + beginUpdateProgress: (message: string) => void; + showNotification: (noteId: number, label: string | number) => Promise; + }; + + updateNotifications.beginUpdateProgress('Updating card'); + await updateNotifications.showNotification(42, '食べる'); + + assert.deepEqual( + overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })), + [ + { id: 'anki-update-progress', variant: 'progress', persistent: true }, + { id: 'anki-update-progress', variant: 'success', persistent: false }, + ], + ); +}); + +test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => { + const overlayNotifications: TestOverlayNotificationPayload[] = []; + const dismissedIds: string[] = []; + const integration = new AnkiIntegration( + { + behavior: { + notificationType: 'overlay', + }, + }, + {} as never, + {} as never, + undefined, + undefined, + undefined, + undefined, + {}, + undefined, + (payload) => { + overlayNotifications.push(payload); + }, + undefined, + undefined, + undefined, + (id) => { + dismissedIds.push(id); + }, + ); + const updateNotifications = integration as unknown as { + beginUpdateProgress: (message: string) => void; + endUpdateProgress: () => void; + }; + + updateNotifications.beginUpdateProgress('Updating card'); + updateNotifications.endUpdateProgress(); + + assert.equal(overlayNotifications[0]?.persistent, true); + assert.deepEqual(dismissedIds, ['anki-update-progress']); +}); + test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const overlayNotifications: TestOverlayNotificationPayload[] = []; diff --git a/src/anki-integration.ts b/src/anki-integration.ts index fcc118e1..b283ada6 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -218,6 +218,8 @@ export class AnkiIntegration { null; private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null = null; + private overlayNotificationDismissCallback: ((id: string) => void) | null = null; + private overlayUpdateProgressActive = false; private updateInProgress = false; private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private parseWarningKeys = new Set(); @@ -265,6 +267,7 @@ export class AnkiIntegration { getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'], shouldRequireRemoteMediaCache?: () => boolean, getYoutubeMediaSourceUrl?: () => Promise | string | null | undefined, + overlayNotificationDismissCallback?: (id: string) => void, ) { this.config = normalizeAnkiIntegrationConfig(config); this.aiConfig = { ...aiConfig }; @@ -280,6 +283,7 @@ export class AnkiIntegration { this.getCachedMediaPath = getCachedMediaPath ?? null; this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null; this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null; + this.overlayNotificationDismissCallback = overlayNotificationDismissCallback ?? null; this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue(); this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.pollingRunner = this.createPollingRunner(); @@ -1203,12 +1207,13 @@ export class AnkiIntegration { private beginUpdateProgress(initialMessage: string): void { if (!this.shouldUseOsdNotifications()) { if (this.shouldUseOverlayNotifications()) { + this.overlayUpdateProgressActive = true; this.overlayNotificationCallback?.({ id: 'anki-update-progress', title: 'Anki update', body: initialMessage, variant: 'progress', - persistent: false, + persistent: true, }); } return; @@ -1220,6 +1225,10 @@ export class AnkiIntegration { private endUpdateProgress(): void { if (!this.shouldUseOsdNotifications()) { + if (this.overlayUpdateProgressActive) { + this.overlayUpdateProgressActive = false; + this.overlayNotificationDismissCallback?.('anki-update-progress'); + } return; } endUpdateProgress(this.uiFeedbackState, (timer) => { @@ -1243,18 +1252,20 @@ export class AnkiIntegration { if (!this.shouldUseOsdNotifications()) { this.updateInProgress = true; if (this.shouldUseOverlayNotifications()) { + this.overlayUpdateProgressActive = true; this.overlayNotificationCallback?.({ id: 'anki-update-progress', title: 'Anki update', body: initialMessage, variant: 'progress', - persistent: false, + persistent: true, }); } try { return await action(); } finally { this.updateInProgress = false; + this.endUpdateProgress(); } } return withUpdateProgress( @@ -1353,6 +1364,7 @@ export class AnkiIntegration { : undefined; if (shouldShowOverlayNotification && this.overlayNotificationCallback) { + this.overlayUpdateProgressActive = false; this.overlayNotificationCallback({ id: 'anki-update-progress', title: 'Anki Card Updated', diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 479b72e2..976db6db 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions { getYoutubeMediaSourceUrl?: () => Promise | string | null | undefined; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime( options.getCachedMediaPath, options.shouldRequireRemoteMediaCache, options.getYoutubeMediaSourceUrl, + options.dismissOverlayNotification, ); integration.start(); options.setAnkiIntegration(integration); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 6f452336..1042c6fc 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = { mpvClient: { send?: (payload: { command: string[] }) => void }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte args.getCachedMediaPath, args.shouldRequireRemoteMediaCache, args.getYoutubeMediaSourceUrl, + args.dismissOverlayNotification, ); } @@ -137,6 +139,7 @@ export function initializeOverlayRuntime( setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: { mpvClient, showDesktopNotification: options.showDesktopNotification, showOverlayNotification: options.showOverlayNotification, + dismissOverlayNotification: options.dismissOverlayNotification, createFieldGroupingCallback: options.createFieldGroupingCallback, knownWordCacheStatePath: options.getKnownWordCacheStatePath(), ...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}), diff --git a/src/main.ts b/src/main.ts index b2af8b26..c6929e85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5925,6 +5925,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ showDesktopNotification, showOverlayNotification: (payload) => overlayNotificationsRuntime.showOverlayNotification(payload), + dismissOverlayNotification: (id) => + overlayNotificationsRuntime.dismissOverlayNotification(id), createFieldGroupingCallback: () => createFieldGroupingCallback(), broadcastRuntimeOptionsChanged: () => overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), @@ -6415,6 +6417,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = showDesktopNotification, showOverlayNotification: (payload) => overlayNotificationsRuntime.showOverlayNotification(payload), + dismissOverlayNotification: (id) => + overlayNotificationsRuntime.dismissOverlayNotification(id), createFieldGroupingCallback: () => createFieldGroupingCallback(), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getCachedMediaPath: (currentVideoPath, kind) => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index cbbe4beb..252a7f8e 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -132,6 +132,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams { getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; @@ -334,6 +335,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( : {}), showDesktopNotification: params.showDesktopNotification, showOverlayNotification: params.showOverlayNotification, + dismissOverlayNotification: params.dismissOverlayNotification, createFieldGroupingCallback: params.createFieldGroupingCallback, broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, getFieldGroupingResolver: params.getFieldGroupingResolver, diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts index 287fba85..6266d394 100644 --- a/src/main/runtime/overlay-runtime-bootstrap.ts +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: { } | null; setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts index 83f7f895..c6d48569 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.test.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => getOverlayWindows: () => [], getResolvedConfig: () => ({}), showDesktopNotification: () => calls.push('notify'), + showOverlayNotification: () => calls.push('show-overlay'), + dismissOverlayNotification: () => calls.push('dismiss-overlay'), createFieldGroupingCallback: () => async () => ({ keepNoteId: 1, deleteNoteId: 2, @@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => deps.refreshCurrentSubtitle?.(); deps.syncOverlayShortcuts(); deps.showDesktopNotification('title', {}); + deps.showOverlayNotification?.({ title: 'title' }); + deps.dismissOverlayNotification?.('notification-id'); const tracker = { close: () => {}, @@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => 'refresh-subtitle', 'sync-shortcuts', 'notify', + 'show-overlay', + 'dismiss-overlay', ]); assert.equal(appState.windowTracker, tracker); assert.deepEqual(appState.ankiIntegration, { id: 'anki' }); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index a9150f91..d3000b2d 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; @@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { }, showDesktopNotification: deps.showDesktopNotification, showOverlayNotification: deps.showOverlayNotification, + dismissOverlayNotification: deps.dismissOverlayNotification, createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts index 90a35960..2886aa6a 100644 --- a/src/main/runtime/overlay-runtime-options.test.ts +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => { getRuntimeOptionsManager: () => null, setAnkiIntegration: () => calls.push('set-anki'), showDesktopNotification: () => calls.push('notify'), + showOverlayNotification: () => calls.push('show-overlay'), + dismissOverlayNotification: () => calls.push('dismiss-overlay'), createFieldGroupingCallback: () => async () => ({ keepNoteId: 1, deleteNoteId: 2, @@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => { options.setWindowTracker(null); options.setAnkiIntegration(null); options.showDesktopNotification('title', {}); + options.showOverlayNotification?.({ title: 'title' }); + options.dismissOverlayNotification?.('notification-id'); assert.deepEqual(calls, [ 'create-main', @@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => { 'set-tracker', 'set-anki', 'notify', + 'show-overlay', + 'dismiss-overlay', ]); }); diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 63d5f688..98e7e08a 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -33,6 +33,7 @@ type OverlayRuntimeOptions = { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { setAnkiIntegration: (integration: unknown | null) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void; + dismissOverlayNotification?: (id: string) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; @@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { setAnkiIntegration: deps.setAnkiIntegration, showDesktopNotification: deps.showDesktopNotification, showOverlayNotification: deps.showOverlayNotification, + dismissOverlayNotification: deps.dismissOverlayNotification, createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),