mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
feat(notifications): add overlay notifications with position config (#110)
This commit is contained in:
+145
-36
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user