feat(notifications): add overlay notifications with position config (#110)

This commit is contained in:
2026-06-10 22:46:52 -07:00
committed by GitHub
parent c09d009a3e
commit 7be1843c41
177 changed files with 7524 additions and 440 deletions
+145 -36
View File
@@ -29,6 +29,8 @@ import {
} from './types/anki';
import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import {
@@ -119,6 +121,15 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
);
}
function toOverlayNotificationImageSource(iconBuffer: Buffer): string {
return `data:image/png;base64,${iconBuffer.toString('base64')}`;
}
interface NotificationIcon {
filePath?: string;
overlayImageSource: string;
}
export class AnkiIntegration {
private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator;
@@ -130,6 +141,8 @@ export class AnkiIntegration {
private osdCallback: ((text: string) => void) | null = null;
private notificationCallback: ((title: string, options: NotificationOptions) => void) | null =
null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null;
private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>();
@@ -166,6 +179,7 @@ export class AnkiIntegration {
knownWordCacheStatePath?: string,
aiConfig: AiConfig = {},
recordCardsMined?: (count: number, noteIds?: number[]) => void,
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
) {
this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
@@ -175,6 +189,7 @@ export class AnkiIntegration {
this.mpvClient = mpvClient;
this.osdCallback = osdCallback || null;
this.notificationCallback = notificationCallback || null;
this.overlayNotificationCallback = overlayNotificationCallback || null;
this.fieldGroupingCallback = fieldGroupingCallback || null;
this.recordCardsMinedCallback = recordCardsMined ?? null;
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
@@ -335,7 +350,7 @@ export class AnkiIntegration {
options,
),
},
showOsdNotification: (text: string) => this.showOsdNotification(text),
showOsdNotification: (text: string) => this.showStatusNotification(text),
showUpdateResult: (message: string, success: boolean) =>
this.showUpdateResult(message, success),
showStatusNotification: (message: string) => this.showStatusNotification(message),
@@ -387,7 +402,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
this.withUpdateProgress(initialMessage, action),
showOsdNotification: (text: string) => this.showOsdNotification(text),
showOsdNotification: (text: string) => this.showStatusNotification(text),
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown as NoteInfo[],
@@ -463,7 +478,7 @@ export class AnkiIntegration {
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message),
showOsdNotification: (message) => this.showStatusNotification(message),
beginUpdateProgress: (initialMessage) => this.beginUpdateProgress(initialMessage),
endUpdateProgress: () => this.endUpdateProgress(),
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
@@ -510,7 +525,7 @@ export class AnkiIntegration {
},
showStatusNotification: (message) => this.showStatusNotification(message),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message),
showOsdNotification: (message) => this.showStatusNotification(message),
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
truncateSentence: (sentence) => this.truncateSentence(sentence),
@@ -525,6 +540,10 @@ export class AnkiIntegration {
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
}
async openNoteInAnki(noteId: number): Promise<void> {
await this.client.openNoteInBrowser(noteId);
}
private isKnownWordCacheEnabled(): boolean {
return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
@@ -860,10 +879,13 @@ export class AnkiIntegration {
private showStatusNotification(message: string): void {
showStatusNotification(message, {
getNotificationType: () => this.config.behavior?.notificationType,
getNotificationType: () => this.getNotificationType(),
showOsd: (text: string) => {
this.showOsdNotification(text);
},
showOverlayNotification: (payload) => {
this.overlayNotificationCallback?.(payload);
},
showSystemNotification: (title: string, options: NotificationOptions) => {
if (this.notificationCallback) {
this.notificationCallback(title, options);
@@ -872,19 +894,51 @@ export class AnkiIntegration {
});
}
private getNotificationType(): NotificationType {
return this.config.behavior?.notificationType ?? 'osd';
}
private shouldUseOsdNotifications(): boolean {
const type = this.getNotificationType();
return type === 'osd' || type === 'osd-system';
}
private shouldUseOverlayNotifications(): boolean {
const type = this.getNotificationType();
return type === 'overlay' || type === 'both';
}
private beginUpdateProgress(initialMessage: string): void {
if (!this.shouldUseOsdNotifications()) {
if (this.shouldUseOverlayNotifications()) {
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
});
}
return;
}
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => {
this.showOsdNotification(text);
});
}
private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
endUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer);
});
}
private clearUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) {
return;
}
clearUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer);
});
@@ -894,6 +948,23 @@ export class AnkiIntegration {
initialMessage: string,
action: () => Promise<T>,
): Promise<T> {
if (!this.shouldUseOsdNotifications()) {
this.updateInProgress = true;
if (this.shouldUseOverlayNotifications()) {
this.overlayNotificationCallback?.({
id: 'anki-update-progress',
title: 'Anki update',
body: initialMessage,
variant: 'progress',
persistent: false,
});
}
try {
return await action();
} finally {
this.updateInProgress = false;
}
}
return withUpdateProgress(
this.uiFeedbackState,
{
@@ -1017,51 +1088,89 @@ export class AnkiIntegration {
? `Updated card: ${label} (${errorSuffix})`
: `Updated card: ${label}`;
const type = this.config.behavior?.notificationType || 'osd';
const type = this.getNotificationType();
if (type === 'osd' || type === 'both') {
if (type === 'osd' || type === 'osd-system') {
this.showUpdateResult(message, errorSuffix === undefined);
} else {
this.clearUpdateProgress();
}
if ((type === 'system' || type === 'both') && this.notificationCallback) {
let notificationIconPath: string | undefined;
const shouldShowOverlayNotification =
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null;
const shouldShowSystemNotification =
(type === 'system' || type === 'both' || type === 'osd-system') &&
this.notificationCallback !== null;
const notificationIcon =
shouldShowOverlayNotification || shouldShowSystemNotification
? await this.generateNotificationIcon(noteId, shouldShowSystemNotification)
: undefined;
if (this.mpvClient && this.mpvClient.currentVideoPath) {
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(
this.mpvClient,
'video',
);
if (!notificationIconSource) {
throw new Error('No media source available for notification icon');
}
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
notificationIconSource,
timestamp,
);
if (iconBuffer && iconBuffer.length > 0) {
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayNotificationCallback({
id: 'anki-update-progress',
title: 'Anki Card Updated',
body: message,
...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
variant: errorSuffix === undefined ? 'success' : 'error',
persistent: false,
actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
});
}
if (shouldShowSystemNotification && this.notificationCallback) {
this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIcon?.filePath,
});
}
if (notificationIcon) {
if (notificationIcon.filePath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath);
}
}
}
private async generateNotificationIcon(
noteId: number,
shouldWriteToFile: boolean,
): Promise<NotificationIcon | undefined> {
if (!this.mpvClient?.currentVideoPath) {
return undefined;
}
try {
const timestamp = this.mpvClient.currentTimePos || 0;
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
if (!notificationIconSource) {
throw new Error('No media source available for notification icon');
}
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
notificationIconSource,
timestamp,
);
if (iconBuffer && iconBuffer.length > 0) {
const notificationIcon: NotificationIcon = {
overlayImageSource: toOverlayNotificationImageSource(iconBuffer),
};
if (shouldWriteToFile) {
try {
notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile(
iconBuffer,
noteId,
);
} catch (err) {
log.warn('Failed to write notification icon:', (err as Error).message);
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
}
return notificationIcon;
}
this.notificationCallback('Anki Card Updated', {
body: message,
icon: notificationIconPath,
});
if (notificationIconPath) {
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
}
} catch (err) {
log.warn('Failed to generate notification icon:', (err as Error).message);
}
return undefined;
}
private showUpdateResult(message: string, success: boolean): void {