mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
feat(notifications): add overlay notifications with position config (#110)
This commit is contained in:
@@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient opens a note in the Anki browser', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
client as unknown as { openNoteInBrowser: (noteId: number) => Promise<void> }
|
||||
).openNoteInBrowser(12345);
|
||||
|
||||
assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
|
||||
@@ -247,6 +247,13 @@ export class AnkiConnectClient {
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async openNoteInBrowser(noteId: number): Promise<void> {
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
throw new Error('Invalid Anki note id');
|
||||
}
|
||||
await this.invoke('guiBrowse', { query: `nid:${noteId}` });
|
||||
}
|
||||
|
||||
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
|
||||
await this.invoke('updateNoteFields', {
|
||||
note: {
|
||||
|
||||
@@ -7,6 +7,14 @@ import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
type TestOverlayNotificationPayload = {
|
||||
title: string;
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant?: string;
|
||||
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
||||
};
|
||||
|
||||
interface IntegrationTestContext {
|
||||
integration: AnkiIntegration;
|
||||
calls: {
|
||||
@@ -406,6 +414,188 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: (videoPath: string, timestamp: number) => Promise<Buffer>;
|
||||
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async (videoPath, timestamp) => {
|
||||
generatedFrom.push({ videoPath, timestamp });
|
||||
return Buffer.from('png');
|
||||
},
|
||||
writeNotificationIconToFile: (iconBuffer, noteId) => {
|
||||
assert.equal(iconBuffer.toString(), 'png');
|
||||
assert.equal(noteId, 42);
|
||||
return notificationIconPath;
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
|
||||
assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(overlayNotifications[0]?.actions, [
|
||||
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
|
||||
]);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: notificationIconPath,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: () => Promise<Buffer>;
|
||||
writeNotificationIconToFile: () => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async () => Buffer.from('png'),
|
||||
writeNotificationIconToFile: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const desktopMessages: string[] = [];
|
||||
const overlayMessages: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{} as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
(title, options) => {
|
||||
desktopMessages.push(`${title}:${options.body ?? ''}`);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
|
||||
|
||||
assert.deepEqual(osdMessages, []);
|
||||
assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
|
||||
assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -271,3 +271,28 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
|
||||
});
|
||||
|
||||
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
|
||||
const statusMessages: string[] = [];
|
||||
const progressMessages: string[] = [];
|
||||
const { service } = createManualUpdateService({
|
||||
showOsdNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
withUpdateProgress: async (message, action) => {
|
||||
progressMessages.push(message);
|
||||
return await action();
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
assert.deepEqual(statusMessages, []);
|
||||
});
|
||||
|
||||
@@ -511,7 +511,6 @@ export class CardCreationService {
|
||||
endTime = startTime + maxMediaDuration;
|
||||
}
|
||||
|
||||
this.deps.showOsdNotification('Creating sentence card...');
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
beginUpdateProgress,
|
||||
createUiFeedbackState,
|
||||
showProgressTick,
|
||||
showStatusNotification,
|
||||
showUpdateResult,
|
||||
} from './ui-feedback';
|
||||
|
||||
@@ -65,3 +66,57 @@ test('showUpdateResult renders failed updates with an x marker', () => {
|
||||
'x Sentence card failed: deck missing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('showStatusNotification falls back to system when overlay delivery is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Waiting for card update', {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showSystemNotification: (title, options) => {
|
||||
calls.push(`system:${title}:${options.body}`);
|
||||
},
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showStatusNotification('Card updated', {
|
||||
getNotificationType: () => 'both',
|
||||
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, ['overlay:Card updated', 'system:SubMiner:Card updated']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationOptions } from '../types/anki';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../types/notification';
|
||||
|
||||
export interface UiFeedbackState {
|
||||
progressDepth: number;
|
||||
@@ -13,8 +14,9 @@ export interface UiFeedbackResult {
|
||||
}
|
||||
|
||||
export interface UiFeedbackNotificationContext {
|
||||
getNotificationType: () => string | undefined;
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (text: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showSystemNotification: (title: string, options: NotificationOptions) => void;
|
||||
}
|
||||
|
||||
@@ -36,13 +38,29 @@ export function showStatusNotification(
|
||||
message: string,
|
||||
context: UiFeedbackNotificationContext,
|
||||
): void {
|
||||
const type = context.getNotificationType() || 'osd';
|
||||
const type = context.getNotificationType() ?? 'osd';
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
if (context.showOverlayNotification) {
|
||||
context.showOverlayNotification({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'info',
|
||||
});
|
||||
} else if (type === 'overlay') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
context.showOsd(message);
|
||||
}
|
||||
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
context.showSystemNotification('SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs captures internal playback feedback command', () => {
|
||||
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
|
||||
|
||||
assert.equal(args.playbackFeedback, 'You can skip by pressing TAB');
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores non-positive numeric session action counts', () => {
|
||||
const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
|
||||
|
||||
|
||||
+14
-1
@@ -43,6 +43,7 @@ export interface CliArgs {
|
||||
playNextSubtitle: boolean;
|
||||
shiftSubDelayPrevLine: boolean;
|
||||
shiftSubDelayNextLine: boolean;
|
||||
playbackFeedback?: string;
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
sessionAction?: SessionActionDispatchRequest;
|
||||
@@ -150,6 +151,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -296,7 +298,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
|
||||
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
|
||||
else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
else if (arg.startsWith('--playback-feedback=')) {
|
||||
const value = arg.slice('--playback-feedback='.length).trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg === '--playback-feedback') {
|
||||
const value = readValue(argv[i + 1])?.trim();
|
||||
if (value) args.playbackFeedback = value;
|
||||
} else if (arg.startsWith('--cycle-runtime-option=')) {
|
||||
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
|
||||
if (parsed) {
|
||||
args.cycleRuntimeOptionId = parsed.id;
|
||||
@@ -556,6 +564,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -631,6 +640,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -697,6 +707,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
@@ -757,6 +768,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.playNextSubtitle &&
|
||||
!args.shiftSubDelayPrevLine &&
|
||||
!args.shiftSubDelayNextLine &&
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
@@ -822,6 +834,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
|
||||
@@ -98,6 +98,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
assert.equal(config.shortcuts.toggleNotificationHistory, 'CommandOrControl+N');
|
||||
assert.equal(config.discordPresence.enabled, true);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'transparent');
|
||||
@@ -152,7 +153,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.stats.autoOpenBrowser, false);
|
||||
assert.equal(config.updates.enabled, true);
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.notificationType, 'both');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
@@ -172,7 +173,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
"updates": {
|
||||
"enabled": false,
|
||||
"checkIntervalHours": 6,
|
||||
"notificationType": "both",
|
||||
"notificationType": "osd-system",
|
||||
"channel": "prerelease"
|
||||
}
|
||||
}`,
|
||||
@@ -182,7 +183,7 @@ test('parses updates config and warns on invalid values', () => {
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().updates.enabled, false);
|
||||
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'both');
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'osd-system');
|
||||
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
@@ -212,6 +213,69 @@ test('parses updates config and warns on invalid values', () => {
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
|
||||
});
|
||||
|
||||
test('accepts overlay notification config values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"updates": {
|
||||
"notificationType": "overlay"
|
||||
},
|
||||
"ankiConnect": {
|
||||
"behavior": {
|
||||
"notificationType": "osd-system"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
|
||||
assert.equal(service.getConfig().updates.notificationType, 'overlay');
|
||||
assert.equal(service.getConfig().ankiConnect.behavior.notificationType, 'osd-system');
|
||||
assert.deepEqual(service.getWarnings(), []);
|
||||
});
|
||||
|
||||
test('parses overlay notification position config and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "top-left"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().notifications.overlayPosition, 'top-left');
|
||||
assert.deepEqual(validService.getWarnings(), []);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"notifications": {
|
||||
"overlayPosition": "bottom-right"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().notifications.overlayPosition,
|
||||
DEFAULT_CONFIG.notifications.overlayPosition,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'notifications.overlayPosition'),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
@@ -2750,7 +2814,7 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
|
||||
/"notificationType": "both",? \/\/ How SubMiner announces available updates\..*Values: overlay \| system \| both \| none \| osd \| osd-system/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
@@ -57,6 +58,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subsync'
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
@@ -101,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'CommandOrControl+N',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
@@ -126,8 +128,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
updates: {
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
notificationType: 'both',
|
||||
channel: 'stable',
|
||||
},
|
||||
notifications: {
|
||||
overlayPosition: 'top-right',
|
||||
},
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
overwriteImage: true,
|
||||
mediaInsertMode: 'append',
|
||||
highlightWord: true,
|
||||
notificationType: 'osd',
|
||||
notificationType: 'overlay',
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildCoreConfigOptionRegistry(
|
||||
@@ -484,9 +489,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
{
|
||||
path: 'updates.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['system', 'osd', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.updates.notificationType,
|
||||
description: 'How SubMiner announces available updates.',
|
||||
description:
|
||||
'How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'updates.channel',
|
||||
@@ -495,6 +502,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.updates.channel,
|
||||
description: 'Release channel used for update checks.',
|
||||
},
|
||||
{
|
||||
path: 'notifications.overlayPosition',
|
||||
kind: 'enum',
|
||||
enumValues: OVERLAY_NOTIFICATION_POSITION_VALUES,
|
||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||
description: 'Position for in-overlay notification cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
@@ -608,5 +622,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleNotificationHistory',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
|
||||
description: 'Accelerator that toggles the overlay notification history panel.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
} from '../../types/notification';
|
||||
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
|
||||
|
||||
export function buildIntegrationConfigOptionRegistry(
|
||||
@@ -158,9 +162,11 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
enumValues: NOTIFICATION_TYPE_VALUES,
|
||||
settingsEnumValues: SETTINGS_NOTIFICATION_TYPE_VALUES,
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
description:
|
||||
'Notification surface used to announce mining and update outcomes. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
|
||||
@@ -27,7 +27,17 @@ export interface ConfigOptionRegistryEntry {
|
||||
kind: ConfigValueKind;
|
||||
defaultValue: unknown;
|
||||
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[];
|
||||
/**
|
||||
* 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[];
|
||||
runtime?: RuntimeOptionRegistryEntry;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'updates',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: ['Overlay notification display behavior.'],
|
||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||
key: 'notifications',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import type { ResolveContext } from './context';
|
||||
import { isNotificationType, type NotificationType } from '../../types/notification';
|
||||
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
|
||||
|
||||
function asNotificationType(value: unknown): NotificationType | undefined {
|
||||
return isNotificationType(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
if (!isObject(context.src.ankiConnect)) {
|
||||
return;
|
||||
@@ -42,6 +47,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'notificationType',
|
||||
'autoUpdateNewCards',
|
||||
]);
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
const {
|
||||
knownWords: _knownWordsConfigFromAnkiConnect,
|
||||
@@ -99,6 +106,22 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
},
|
||||
};
|
||||
|
||||
if (hasOwn(behavior, 'notificationType')) {
|
||||
const parsed = asNotificationType(behavior.notificationType);
|
||||
if (parsed === undefined) {
|
||||
context.resolved.ankiConnect.behavior.notificationType =
|
||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
|
||||
context.warn(
|
||||
'ankiConnect.behavior.notificationType',
|
||||
behavior.notificationType,
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
} else {
|
||||
context.resolved.ankiConnect.behavior.notificationType = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(ac.isLapis)) {
|
||||
const lapisEnabled = asBoolean(ac.isLapis.enabled);
|
||||
if (lapisEnabled !== undefined) {
|
||||
@@ -289,8 +312,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
@@ -328,11 +349,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
||||
return value === 'append' || value === 'prepend' ? value : undefined;
|
||||
};
|
||||
const asNotificationType = (value: unknown): 'osd' | 'system' | 'both' | 'none' | undefined => {
|
||||
return value === 'osd' || value === 'system' || value === 'both' || value === 'none'
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
const mapLegacy = <T>(
|
||||
key: string,
|
||||
parse: (value: unknown) => T | undefined,
|
||||
@@ -633,7 +649,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.behavior.notificationType = value;
|
||||
},
|
||||
context.resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'osd', 'system', 'both', or 'none'.",
|
||||
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
@@ -194,19 +195,14 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const notificationType = asString(src.updates.notificationType);
|
||||
if (
|
||||
notificationType === 'system' ||
|
||||
notificationType === 'osd' ||
|
||||
notificationType === 'both' ||
|
||||
notificationType === 'none'
|
||||
) {
|
||||
if (isNotificationType(notificationType)) {
|
||||
resolved.updates.notificationType = notificationType;
|
||||
} else if (src.updates.notificationType !== undefined) {
|
||||
warn(
|
||||
'updates.notificationType',
|
||||
src.updates.notificationType,
|
||||
resolved.updates.notificationType,
|
||||
'Expected system, osd, both, or none.',
|
||||
'Expected overlay, system, both, none, osd, or osd-system.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'toggleNotificationHistory',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
@@ -323,4 +320,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
resolved.subtitlePosition.yPercent = y;
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.notifications)) {
|
||||
const overlayPosition = asString(src.notifications.overlayPosition);
|
||||
if (isOverlayNotificationPosition(overlayPosition)) {
|
||||
resolved.notifications.overlayPosition = overlayPosition;
|
||||
} else if (src.notifications.overlayPosition !== undefined) {
|
||||
warn(
|
||||
'notifications.overlayPosition',
|
||||
src.notifications.overlayPosition,
|
||||
resolved.notifications.overlayPosition,
|
||||
'Expected top-left, top, or top-right.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Startup warmups',
|
||||
'Logging',
|
||||
'Updates',
|
||||
'Notifications',
|
||||
'Immersion tracking',
|
||||
].map((section, index) => [section, index]),
|
||||
);
|
||||
@@ -411,6 +412,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
) {
|
||||
return { category: 'behavior', section: 'Playback Behavior' };
|
||||
}
|
||||
if (path.startsWith('notifications.')) {
|
||||
return { category: 'behavior', section: 'Notifications' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
@@ -478,6 +482,7 @@ function topSection(path: string): string {
|
||||
mpv: 'mpv Playback',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
notifications: 'Notifications',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
@@ -577,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleNotificationHistory' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
@@ -687,6 +693,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'logging.level' ||
|
||||
path === 'logging.rotation' ||
|
||||
pathStartsWith(path, 'logging.files') ||
|
||||
pathStartsWith(path, 'notifications') ||
|
||||
path === 'youtube.primarySubLanguages' ||
|
||||
pathStartsWith(path, 'jimaku') ||
|
||||
pathStartsWith(path, 'subsync')
|
||||
@@ -710,7 +717,9 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
...(option?.settingsEnumValues || option?.enumValues
|
||||
? { enumValues: option.settingsEnumValues ?? option.enumValues }
|
||||
: {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
JimakuMediaInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
OverlayNotificationPayload,
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
@@ -40,6 +41,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -103,6 +105,8 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.createFieldGroupingCallback(),
|
||||
options.getKnownWordCacheStatePath(),
|
||||
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
|
||||
undefined,
|
||||
options.showOverlayNotification,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -2,6 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
|
||||
|
||||
function waitTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
@@ -277,20 +281,80 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
|
||||
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
|
||||
const calls: string[] = [];
|
||||
const { deps } = makeDeps({
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handleFirstRunSetup');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
try {
|
||||
assert.ok(calls.includes('handleFirstRunSetup'));
|
||||
assert.ok(calls.includes('handleInitialArgs'));
|
||||
assert.equal(calls.includes('loadYomitanExtension:done'), false);
|
||||
} finally {
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
let releaseYomitan!: () => void;
|
||||
const yomitanGate = new Promise<void>((resolve) => {
|
||||
releaseYomitan = resolve;
|
||||
});
|
||||
const { deps } = makeDeps({
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('loadYomitanExtension:start');
|
||||
await yomitanGate;
|
||||
calls.push('loadYomitanExtension:done');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
} as Partial<AppReadyRuntimeDeps>);
|
||||
|
||||
const readyPromise = runAppReadyRuntime(deps);
|
||||
await waitTurn();
|
||||
|
||||
assert.equal(calls.includes('handleInitialArgs'), false);
|
||||
|
||||
releaseYomitan();
|
||||
await readyPromise;
|
||||
|
||||
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime starts background warmups after overlay startup', async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('loadSubtitlePosition'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('createMpvClient'));
|
||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
playbackFeedback: undefined,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
@@ -252,6 +253,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
showPlaybackFeedback: (text) => {
|
||||
calls.push(`feedback:${text}`);
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
@@ -493,6 +497,15 @@ test('handleCliCommand reports async mine errors to OSD', async () => {
|
||||
assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
|
||||
});
|
||||
|
||||
test('handleCliCommand routes playback feedback through configured feedback surface', () => {
|
||||
const { deps, calls, osd } = createDeps();
|
||||
|
||||
handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps);
|
||||
|
||||
assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies socket path and connects on start', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
log: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
@@ -128,6 +129,7 @@ interface MpvCliRuntime {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: () => MpvClientLike | null;
|
||||
showOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface TexthookerCliRuntime {
|
||||
@@ -295,6 +297,7 @@ export function createCliCommandDepsRuntime(
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
showMpvOsd: options.mpv.showOsd,
|
||||
showPlaybackFeedback: options.mpv.showPlaybackFeedback,
|
||||
log: options.log,
|
||||
logDebug: options.logDebug,
|
||||
warn: options.warn,
|
||||
@@ -546,6 +549,9 @@ export function handleCliCommand(
|
||||
'shiftSubDelayNextLine',
|
||||
'Shift subtitle delay failed',
|
||||
);
|
||||
} else if (args.playbackFeedback) {
|
||||
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
|
||||
showFeedback(args.playbackFeedback);
|
||||
} else if (args.cycleRuntimeOptionId !== undefined) {
|
||||
dispatchCliSessionAction(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
const calls: string[] = [];
|
||||
const sentCommands: (string | number)[][] = [];
|
||||
const osd: string[] = [];
|
||||
const playbackFeedback: string[] = [];
|
||||
const options: Parameters<typeof handleMpvCommandFromIpc>[1] = {
|
||||
specialCommands: {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
@@ -38,6 +39,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
showPlaybackFeedback: (text) => {
|
||||
playbackFeedback.push(text);
|
||||
},
|
||||
mpvReplaySubtitle: () => {
|
||||
calls.push('replay');
|
||||
},
|
||||
@@ -55,7 +59,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
...overrides,
|
||||
};
|
||||
return { options, calls, sentCommands, osd };
|
||||
return { options, calls, sentCommands, osd, playbackFeedback };
|
||||
}
|
||||
|
||||
test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
@@ -65,41 +69,53 @@ test('handleMpvCommandFromIpc forwards regular mpv commands', () => {
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle position keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc routes show-text through playback feedback', () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['show-text', 'Primary subtitle: hover', '1500'], options);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Primary subtitle: hover']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle position keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-pos', 1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-pos', 1]]);
|
||||
assert.deepEqual(osd, ['Subtitle position: ${sub-pos}']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle position: ${sub-pos}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits resolved osd for primary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
test('handleMpvCommandFromIpc emits resolved feedback for primary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions({
|
||||
resolveProxyCommandOsd: async () => 'Subtitle track: Internal #3 - Japanese (active)',
|
||||
});
|
||||
handleMpvCommandFromIpc(['cycle', 'sid'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['cycle', 'sid']]);
|
||||
assert.deepEqual(osd, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle track: Internal #3 - Japanese (active)']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits resolved osd for secondary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle track keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions({
|
||||
resolveProxyCommandOsd: async () =>
|
||||
'Secondary subtitle track: External #8 - English Commentary',
|
||||
});
|
||||
handleMpvCommandFromIpc(['set_property', 'secondary-sid', 'auto'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['set_property', 'secondary-sid', 'auto']]);
|
||||
assert.deepEqual(osd, ['Secondary subtitle track: External #8 - English Commentary']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Secondary subtitle track: External #8 - English Commentary']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd } = createOptions();
|
||||
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
|
||||
const { options, sentCommands, osd, playbackFeedback } = createOptions();
|
||||
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
|
||||
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
|
||||
assert.deepEqual(osd, []);
|
||||
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
mpvPlayNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
@@ -68,13 +69,14 @@ function showResolvedProxyCommandOsd(
|
||||
): void {
|
||||
const template = resolveProxyCommandOsdTemplate(command);
|
||||
if (!template) return;
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
|
||||
const emit = async () => {
|
||||
try {
|
||||
const resolved = await options.resolveProxyCommandOsd?.(command);
|
||||
options.showMpvOsd(resolved || template);
|
||||
showFeedback(resolved || template);
|
||||
} catch {
|
||||
options.showMpvOsd(template);
|
||||
showFeedback(template);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,6 +144,15 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === 'show-text') {
|
||||
const message = (typeof command[1] === 'string' ? command[1] : String(command[1] ?? '')).trim();
|
||||
if (message) {
|
||||
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
|
||||
showFeedback(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.isMpvConnected()) {
|
||||
if (first === options.specialCommands.REPLAY_SUBTITLE) {
|
||||
options.mpvReplaySubtitle();
|
||||
|
||||
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
@@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: (update) => {
|
||||
@@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async (update) => {
|
||||
@@ -1262,6 +1267,44 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards valid overlay notification actions', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
handleOverlayNotificationAction: ((
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => {
|
||||
actions.push({ notificationId, actionId, noteId });
|
||||
}) as IpcServiceDeps['handleOverlayNotificationAction'],
|
||||
} as Partial<IpcServiceDeps>),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
|
||||
assert.ok(actionHandler);
|
||||
|
||||
actionHandler({}, null);
|
||||
actionHandler({}, { notificationId: '', actionId: 'install-update' });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 },
|
||||
);
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
);
|
||||
|
||||
assert.deepEqual(actions, [
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined },
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
@@ -1289,6 +1332,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface IpcServiceDeps {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -80,6 +85,7 @@ export interface IpcServiceDeps {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -223,6 +229,25 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOverlayNotificationActionPayload(
|
||||
payload: unknown,
|
||||
): { notificationId: string; actionId: string; noteId?: number } | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const notificationId = record.notificationId;
|
||||
const actionId = record.actionId;
|
||||
const noteId = record.noteId;
|
||||
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
|
||||
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
|
||||
if (
|
||||
noteId !== undefined &&
|
||||
(typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -242,6 +267,11 @@ export interface IpcDepsRuntimeOptions {
|
||||
interactive: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -262,6 +292,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
getMarkWatchedKey: () => string;
|
||||
getOverlayNotificationPosition: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -312,6 +343,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
|
||||
quitApp: options.quitApp,
|
||||
@@ -349,6 +381,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
|
||||
getStatsToggleKey: options.getStatsToggleKey,
|
||||
getMarkWatchedKey: options.getMarkWatchedKey,
|
||||
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
@@ -473,6 +506,22 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.onOverlayModalOpened(parsedModal, senderWindow);
|
||||
});
|
||||
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
|
||||
const parsedPayload = parseOverlayNotificationActionPayload(payload);
|
||||
if (!parsedPayload) return;
|
||||
void Promise.resolve(
|
||||
deps.handleOverlayNotificationAction?.(
|
||||
parsedPayload.notificationId,
|
||||
parsedPayload.actionId,
|
||||
parsedPayload.noteId,
|
||||
),
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
'Failed to handle overlay notification action:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.youtubePickerResolve,
|
||||
@@ -641,6 +690,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getMarkWatchedKey();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
|
||||
return deps.getOverlayNotificationPosition();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
OverlayNotificationPayload,
|
||||
WindowGeometry,
|
||||
} from '../../types';
|
||||
|
||||
@@ -19,6 +20,7 @@ type CreateAnkiIntegrationArgs = {
|
||||
subtitleTimingTracker: unknown;
|
||||
mpvClient: { send?: (payload: { command: string[] }) => void };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -61,6 +63,8 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.createFieldGroupingCallback(),
|
||||
args.knownWordCacheStatePath,
|
||||
args.aiConfig,
|
||||
undefined,
|
||||
args.showOverlayNotification,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +127,7 @@ export function initializeOverlayRuntime(
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -156,6 +161,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
getAnkiIntegration?: () => unknown | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -191,6 +197,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
subtitleTimingTracker,
|
||||
mpvClient,
|
||||
showDesktopNotification: options.showDesktopNotification,
|
||||
showOverlayNotification: options.showOverlayNotification,
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +154,127 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
|
||||
test('macOS dismisses overlay loading OSD when tracker recovers', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const osdMessages: string[] = [];
|
||||
const dismissedOsds: string[] = [];
|
||||
let tracking = false;
|
||||
let geometry: WindowTrackerStub['getGeometry'] extends () => infer T ? T : never = null;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => tracking,
|
||||
getGeometry: () => geometry,
|
||||
isTargetWindowFocused: () => tracking,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissedOsds.push('dismiss');
|
||||
},
|
||||
} as never);
|
||||
|
||||
run();
|
||||
tracking = true;
|
||||
geometry = { x: 0, y: 0, width: 1280, height: 720 };
|
||||
run();
|
||||
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.deepEqual(dismissedOsds, ['dismiss']);
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-native overlay shows loading OSD until renderer content is visible', () => {
|
||||
const { window, calls, setContentReady } = createMainWindowRecorder();
|
||||
let loadingShown = false;
|
||||
const osdMessages: string[] = [];
|
||||
const dismissedOsds: string[] = [];
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: loadingShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
loadingShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissedOsds.push('dismiss');
|
||||
},
|
||||
} as never);
|
||||
|
||||
setContentReady(false);
|
||||
run();
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, true);
|
||||
assert.deepEqual(osdMessages, ['Overlay loading...']);
|
||||
assert.deepEqual(dismissedOsds, []);
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('show-inactive'));
|
||||
|
||||
setContentReady(true);
|
||||
run();
|
||||
|
||||
assert.equal(loadingShown, false);
|
||||
assert.deepEqual(dismissedOsds, ['dismiss']);
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay stays hidden and emits loading OSD while tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -197,7 +317,7 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
assert.ok(calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
dismissOverlayLoadingOsd?: () => void;
|
||||
shouldShowOverlayLoadingOsd?: () => boolean;
|
||||
markOverlayLoadingOsdShown?: () => void;
|
||||
resetOverlayLoadingOsdSuppression?: () => void;
|
||||
@@ -310,8 +311,18 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!args.isWindowsPlatform &&
|
||||
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||
|
||||
const isWaitingForOverlayContentReady = (): boolean => {
|
||||
const hasWebContents =
|
||||
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
|
||||
return (
|
||||
!mainWindow.isVisible() &&
|
||||
hasWebContents &&
|
||||
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
|
||||
);
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
if (!args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
}
|
||||
if (args.shouldShowOverlayLoadingOsd && !args.shouldShowOverlayLoadingOsd()) {
|
||||
@@ -320,6 +331,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.showOverlayLoadingOsd('Overlay loading...');
|
||||
args.markOverlayLoadingOsdShown?.();
|
||||
};
|
||||
const maybeDismissOverlayLoadingOsd = (): void => {
|
||||
args.dismissOverlayLoadingOsd?.();
|
||||
};
|
||||
|
||||
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
|
||||
if (
|
||||
@@ -350,6 +364,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.resetOverlayLoadingOsdSuppression?.();
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
if (args.isWindowsPlatform) {
|
||||
clearPendingWindowsOverlayReveal(mainWindow);
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
@@ -371,7 +386,15 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
if (isWaitingForOverlayContentReady()) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
} else {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
}
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
@@ -432,6 +455,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
(mainWindow.isVisible() || hasRetainedTrackedGeometry)
|
||||
) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
maybeDismissOverlayLoadingOsd();
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
|
||||
@@ -116,6 +116,7 @@ export function createOverlayWindow(
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onVisibleWindowFocused?: () => void;
|
||||
onWindowDidFinishLoad?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
|
||||
yomitanSession?: Session | null;
|
||||
@@ -139,6 +140,7 @@ export function createOverlayWindow(
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.onWindowDidFinishLoad?.();
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
mineSentenceCount: (count) => calls.push(`mine:${count}`),
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
toggleNotificationHistory: () => calls.push('notification-history'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
|
||||
mineSentenceCount: (count: number) => void;
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
toggleNotificationHistory: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -79,6 +80,9 @@ export async function dispatchSessionAction(
|
||||
case 'toggleSubtitleSidebar':
|
||||
deps.toggleSubtitleSidebar();
|
||||
return;
|
||||
case 'toggleNotificationHistory':
|
||||
deps.toggleNotificationHistory();
|
||||
return;
|
||||
case 'markAudioCard':
|
||||
await deps.markLastCardAsAudioCard();
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
|
||||
assert.deepEqual(
|
||||
result.bindings.map((binding) => binding.sourcePath),
|
||||
['keybindings[0].key'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||
['unsupported:shortcuts.openJimaku'],
|
||||
|
||||
@@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
|
||||
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
|
||||
];
|
||||
|
||||
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
||||
|
||||
@@ -269,7 +269,7 @@ test('runAppReadyRuntime loads Yomitan before headless overlay fallback initiali
|
||||
]);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||
test('runAppReadyRuntime auto-initializes overlay runtime before warmups and Yomitan', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runAppReadyRuntime({
|
||||
@@ -354,9 +354,10 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
|
||||
shouldSkipHeavyStartup: () => false,
|
||||
});
|
||||
|
||||
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||
assert.ok(calls.indexOf('warmups') !== -1);
|
||||
assert.ok(calls.indexOf('init-overlay') < calls.indexOf('warmups'));
|
||||
assert.equal(calls.includes('load-yomitan'), false);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps {
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
|
||||
@@ -229,6 +230,31 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const startupStartedAtMs = now();
|
||||
const ensureYomitanExtensionReady =
|
||||
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
|
||||
let firstRunSetupHandled = false;
|
||||
let initialArgsHandled = false;
|
||||
let backgroundWarmupsHandled = false;
|
||||
const handleFirstRunSetupOnce = async (): Promise<void> => {
|
||||
if (firstRunSetupHandled) {
|
||||
return;
|
||||
}
|
||||
firstRunSetupHandled = true;
|
||||
await deps.handleFirstRunSetup();
|
||||
};
|
||||
const handleInitialArgsOnce = (): void => {
|
||||
if (initialArgsHandled) {
|
||||
return;
|
||||
}
|
||||
initialArgsHandled = true;
|
||||
deps.handleInitialArgs();
|
||||
};
|
||||
const startBackgroundWarmupsOnce = (): void => {
|
||||
if (backgroundWarmupsHandled) {
|
||||
return;
|
||||
}
|
||||
backgroundWarmupsHandled = true;
|
||||
deps.startBackgroundWarmups();
|
||||
};
|
||||
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
@@ -247,7 +273,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
handleInitialArgsOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,8 +282,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.reloadConfig();
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
return;
|
||||
}
|
||||
@@ -279,8 +305,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
for (const warning of deps.getConfigWarnings()) {
|
||||
deps.logConfigWarning(warning);
|
||||
}
|
||||
deps.startBackgroundWarmups();
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
deps.createMpvClient();
|
||||
@@ -326,16 +350,24 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
|
||||
if (deps.texthookerOnlyMode) {
|
||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||
startBackgroundWarmupsOnce();
|
||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||
await ensureYomitanExtensionReady();
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.initializeOverlayRuntime();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||
await ensureYomitanExtensionReady();
|
||||
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
startBackgroundWarmupsOnce();
|
||||
} else {
|
||||
startBackgroundWarmupsOnce();
|
||||
await ensureYomitanExtensionReady();
|
||||
}
|
||||
}
|
||||
|
||||
await deps.handleFirstRunSetup();
|
||||
deps.handleInitialArgs();
|
||||
await handleFirstRunSetupOnce();
|
||||
handleInitialArgsOnce();
|
||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
|
||||
openControllerSelect: string | null | undefined;
|
||||
openControllerDebug: string | null | undefined;
|
||||
toggleSubtitleSidebar: string | null | undefined;
|
||||
toggleNotificationHistory: string | null | undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredShortcuts(
|
||||
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
|
||||
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
|
||||
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ export interface ConfiguredWindowsMpvLaunch {
|
||||
}
|
||||
|
||||
export function buildWindowsMpvPluginRuntimeConfig(
|
||||
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'>,
|
||||
config: Pick<
|
||||
ResolvedConfig,
|
||||
'ankiConnect' | 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'
|
||||
>,
|
||||
): SubminerPluginRuntimeScriptOptConfig {
|
||||
return {
|
||||
socketPath: config.mpv.socketPath,
|
||||
@@ -27,6 +30,9 @@ export function buildWindowsMpvPluginRuntimeConfig(
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
osdMessages:
|
||||
config.ankiConnect.behavior.notificationType === 'osd' ||
|
||||
config.ankiConnect.behavior.notificationType === 'osd-system',
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
|
||||
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
});
|
||||
} finally {
|
||||
@@ -377,6 +378,7 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStart: false,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: true,
|
||||
});
|
||||
} finally {
|
||||
|
||||
+428
-50
@@ -64,6 +64,7 @@ import {
|
||||
type ForegroundSuppressionGraceState,
|
||||
mapOverlayMeasurementForPointerInteraction,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './main/runtime/linux-overlay-pointer-interaction';
|
||||
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
|
||||
@@ -140,9 +141,13 @@ import type {
|
||||
SubtitleData,
|
||||
SubtitleMiningContext,
|
||||
SubtitlePosition,
|
||||
OverlayNotificationPayload,
|
||||
OverlayNotificationEventPayload,
|
||||
NotificationType,
|
||||
UpdateChannel,
|
||||
WindowGeometry,
|
||||
} from './types';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
@@ -189,6 +194,7 @@ import {
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
@@ -601,7 +607,21 @@ import {
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import {
|
||||
INSTALL_UPDATE_ACTION_ID,
|
||||
notifyUpdateAvailable,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { createOverlayLoadingOsdController } from './main/runtime/overlay-loading-osd';
|
||||
import { createMaybeStartOverlayLoadingOsdHandler } from './main/runtime/overlay-loading-osd-start';
|
||||
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
|
||||
import { createOverlayNotificationDelivery } from './main/runtime/overlay-notification-delivery';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
type ConfiguredStatusNotificationOptions,
|
||||
} from './main/runtime/configured-status-notification';
|
||||
import { resolveOverlayReadinessNotificationType } from './main/runtime/notification-routing';
|
||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||
import {
|
||||
runUpdateCliCommand,
|
||||
@@ -1234,7 +1254,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showYoutubeFlowStatusNotification(text),
|
||||
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
|
||||
notifyPrimarySubtitleLoaded: () =>
|
||||
youtubePrimarySubtitleNotificationRuntime.markCurrentMediaPrimarySubtitleLoaded(),
|
||||
@@ -1297,7 +1317,6 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||
},
|
||||
isSignalTargetReady: (signal) =>
|
||||
isTokenizationWarmupReady() &&
|
||||
isVisibleOverlayAutoplayTargetReady(
|
||||
{
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
@@ -1469,6 +1488,9 @@ function getMpvPluginRuntimeConfig() {
|
||||
autoStart: config.mpv.autoStartSubMiner,
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
osdMessages:
|
||||
config.ankiConnect.behavior.notificationType === 'osd' ||
|
||||
config.ankiConnect.behavior.notificationType === 'osd-system',
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
};
|
||||
}
|
||||
@@ -1714,7 +1736,7 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
||||
setSubsyncInProgress: (inProgress) => {
|
||||
appState.subsyncInProgress = inProgress;
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showSubsyncStatusNotification(text),
|
||||
openManualPicker: (payload) => {
|
||||
openOverlayHostedModalWithOsd(
|
||||
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
||||
@@ -1736,7 +1758,10 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim
|
||||
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
|
||||
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
|
||||
const startupOsdSequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
});
|
||||
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||
@@ -1767,11 +1792,21 @@ function isYoutubePlaybackActiveNow(): boolean {
|
||||
}
|
||||
|
||||
function reportYoutubeSubtitleFailure(message: string): void {
|
||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
if (type === 'osd' || type === 'both') {
|
||||
const type = getConfiguredStatusNotificationType();
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
showOverlayNotification({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
showMpvOsd(message);
|
||||
}
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
try {
|
||||
showDesktopNotification('SubMiner', { body: message });
|
||||
} catch {
|
||||
@@ -1782,13 +1817,22 @@ function reportYoutubeSubtitleFailure(message: string): void {
|
||||
|
||||
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
||||
if (youtubeFlowRuntime.hasActiveSession()) {
|
||||
showMpvOsd('YouTube subtitle flow already in progress.');
|
||||
showConfiguredStatusNotification('YouTube subtitle flow already in progress.', {
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentMediaPath =
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
|
||||
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
|
||||
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
|
||||
showConfiguredStatusNotification(
|
||||
'YouTube subtitle picker is only available during YouTube playback.',
|
||||
{
|
||||
title: 'YouTube subtitles',
|
||||
variant: 'warning',
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
await youtubeFlowRuntime.openManualPicker({
|
||||
@@ -1861,10 +1905,16 @@ async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
|
||||
function signalCurrentSubtitleAutoplayReady(): void {
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
const payload = getCurrentAutoplaySubtitlePayload();
|
||||
if (!payload) {
|
||||
if (payload) {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
return;
|
||||
}
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
if (!appState.currentSubText.trim()) {
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||
@@ -1897,6 +1947,8 @@ let subtitleSidebarRequestedOpen = false;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||
|
||||
function getCurrentAutoplayMediaPath(): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
@@ -1971,6 +2023,7 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
},
|
||||
deferUncachedRefresh: true,
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
setCurrentSecondarySubText: (text) => {
|
||||
if (appState.mpvClient) {
|
||||
@@ -1986,6 +2039,38 @@ async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||
return;
|
||||
}
|
||||
if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
const text = appState.currentSubText;
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||
}
|
||||
|
||||
async function primeAutoplaySubtitleFromParsedCues(
|
||||
mediaPath: string,
|
||||
cues: SubtitleCue[],
|
||||
@@ -2134,7 +2219,7 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
|
||||
return windowTracker.isTargetWindowFocused();
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
openRuntimeOptionsPalette();
|
||||
},
|
||||
@@ -2177,7 +2262,9 @@ syncOverlayShortcutsForModal = (isActive: boolean): void => {
|
||||
|
||||
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
{
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
},
|
||||
);
|
||||
@@ -2536,8 +2623,9 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onSyncStatus: (event) => {
|
||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startupOsdSequencer,
|
||||
});
|
||||
@@ -2614,7 +2702,10 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: () => process.platform === 'darwin',
|
||||
isWindowsPlatform: () => process.platform === 'win32',
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
showMpvOsd(message);
|
||||
showOverlayLoadingStatusNotification(message);
|
||||
},
|
||||
dismissOverlayLoadingOsd: () => {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
},
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
shouldRunLinuxOverlayZOrderKeepAlive() &&
|
||||
@@ -2643,6 +2734,7 @@ const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
@@ -2657,6 +2749,8 @@ const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState =
|
||||
};
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let linuxOverlayInputShapeActive = false;
|
||||
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||
// moves off measured subtitle/sidebar rects onto the popup.
|
||||
@@ -2679,6 +2773,7 @@ const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHa
|
||||
function resetVisibleOverlayInputState(): void {
|
||||
visibleOverlayInteractionActive = false;
|
||||
linuxOverlayInputShapeActive = false;
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
linuxOverlayInteractiveHint = false;
|
||||
overlayContentMeasurementStore.clear('visible');
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
@@ -3151,6 +3246,23 @@ function shouldUseLinuxOverlayInputShape(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||
);
|
||||
}
|
||||
|
||||
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||
}
|
||||
|
||||
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||
linuxVisibleOverlayStartupInputPrimed = false;
|
||||
clearLinuxVisibleOverlayStartupInputGrace();
|
||||
}
|
||||
|
||||
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||
if (!shouldUseLinuxOverlayInputShape()) {
|
||||
linuxOverlayInputShapeActive = false;
|
||||
@@ -3189,6 +3301,28 @@ function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||
if (shouldUseLinuxOverlayInputShape()) return;
|
||||
if (
|
||||
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
linuxVisibleOverlayStartupInputPrimed = true;
|
||||
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||
updateLinuxOverlayPointerInteractionActive(true);
|
||||
}
|
||||
|
||||
const linuxOverlayZOrderKeepAliveDeps = {
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -3249,7 +3383,8 @@ const linuxOverlayPointerInteractionDeps = {
|
||||
getCursorScreenPoint: () =>
|
||||
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||
getRendererInteractiveHint: () =>
|
||||
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||
@@ -3296,6 +3431,177 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
|
||||
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
||||
}
|
||||
|
||||
function isVisibleOverlayContentReady(): boolean {
|
||||
const overlayWindow = overlayManager.getMainWindow();
|
||||
return Boolean(
|
||||
overlayManager.getVisibleOverlayVisible() &&
|
||||
overlayWindow &&
|
||||
isOverlayWindowReadyForNotification(overlayWindow),
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredStatusNotificationType(): NotificationType {
|
||||
const configuredType = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||
}
|
||||
|
||||
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
}
|
||||
|
||||
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
|
||||
send: (payload) => {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||
},
|
||||
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
});
|
||||
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null = null;
|
||||
|
||||
function flushQueuedOverlayNotifications(): void {
|
||||
overlayNotificationDelivery.flush();
|
||||
}
|
||||
|
||||
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||
overlayNotificationDelivery.send(payload);
|
||||
}
|
||||
|
||||
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||
sendOverlayNotificationEvent(
|
||||
withConfiguredOverlayNotificationPosition(payload, getResolvedConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||
const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId);
|
||||
if (activeIntegrationOpen) {
|
||||
await activeIntegrationOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
const effectiveAnkiConfig =
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const fallbackClient = new AnkiConnectClient(
|
||||
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
|
||||
);
|
||||
await fallbackClient.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
|
||||
function showConfiguredStatusNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
notifyConfiguredStatus(
|
||||
message,
|
||||
{
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||
showOsd: (text) => showMpvOsd(text),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, notificationOptions) =>
|
||||
showDesktopNotification(title, notificationOptions),
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function showConfiguredPlaybackFeedback(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
showConfiguredStatusNotification(message, {
|
||||
...getPlaybackFeedbackNotificationOptions(message),
|
||||
...options,
|
||||
delivery: 'feedback',
|
||||
});
|
||||
}
|
||||
|
||||
function showSubsyncStatusNotification(message: string): void {
|
||||
const syncing = message.startsWith('Subsync: syncing');
|
||||
const failed = message.toLowerCase().includes('failed');
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'subsync-status',
|
||||
title: 'Subsync',
|
||||
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||
persistent: syncing,
|
||||
desktop: !syncing,
|
||||
});
|
||||
}
|
||||
|
||||
function showYoutubeFlowStatusNotification(message: string): void {
|
||||
const progress =
|
||||
message.startsWith('Downloading subtitles') ||
|
||||
message.startsWith('Loading subtitles') ||
|
||||
message.startsWith('Getting subtitles') ||
|
||||
message === 'Opening YouTube video';
|
||||
showConfiguredStatusNotification(message, {
|
||||
id: 'youtube-subtitles-status',
|
||||
title: 'YouTube subtitles',
|
||||
variant: progress ? 'progress' : 'info',
|
||||
persistent: progress,
|
||||
desktop: !progress,
|
||||
});
|
||||
}
|
||||
|
||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||
if (!overlayLoadingOsdController) {
|
||||
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['show-text', '', '1']);
|
||||
},
|
||||
setInterval: (callback, delayMs) => {
|
||||
const timer = setInterval(callback, delayMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||
},
|
||||
});
|
||||
}
|
||||
return overlayLoadingOsdController;
|
||||
}
|
||||
|
||||
function showOverlayLoadingStatusNotification(message: string): void {
|
||||
void message;
|
||||
getOverlayLoadingOsdController().start();
|
||||
}
|
||||
|
||||
function dismissOverlayLoadingStatusNotification(): void {
|
||||
getOverlayLoadingOsdController().stop();
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-overlay-loading-ready']);
|
||||
dismissOverlayNotification('overlay-loading-status');
|
||||
}
|
||||
|
||||
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => overlayManager.getVisibleOverlayVisible(),
|
||||
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||
startOverlayLoadingOsd: () => {
|
||||
showOverlayLoadingStatusNotification('Overlay loading...');
|
||||
},
|
||||
});
|
||||
|
||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
@@ -3386,12 +3692,12 @@ function openOverlayHostedModalWithOsd(
|
||||
void openModal(createOverlayHostedModalOpenDeps())
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
showMpvOsd(unavailableMessage);
|
||||
showConfiguredStatusNotification(unavailableMessage, { variant: 'warning' });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(failureLogMessage, error);
|
||||
showMpvOsd(unavailableMessage);
|
||||
showConfiguredStatusNotification(unavailableMessage, { variant: 'error' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3422,7 +3728,7 @@ function openSessionHelpOverlay(): void {
|
||||
function openCharacterDictionaryManagerOverlay(): void {
|
||||
openCharacterDictionaryManagerWithConfigGate({
|
||||
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
openManager: () => {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openCharacterDictionaryManagerModalRuntime,
|
||||
@@ -3431,6 +3737,7 @@ function openCharacterDictionaryManagerOverlay(): void {
|
||||
);
|
||||
},
|
||||
showOsd: (message) => showMpvOsd(message),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
@@ -3454,7 +3761,10 @@ function openControllerDebugOverlay(): void {
|
||||
|
||||
function openPlaylistBrowser(): void {
|
||||
if (!appState.mpvClient?.connected) {
|
||||
showMpvOsd('Playlist browser requires active playback.');
|
||||
showConfiguredStatusNotification('Playlist browser requires active playback.', {
|
||||
title: 'Playlist browser',
|
||||
variant: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
openOverlayHostedModalWithOsd(
|
||||
@@ -3636,7 +3946,7 @@ const {
|
||||
void appState.jellyfinRemoteSession?.reportPlaying(payload);
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
showConfiguredStatusNotification(text, { title: 'Jellyfin' });
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
mediaRuntime.updateCurrentMediaTitle(title);
|
||||
@@ -3770,7 +4080,7 @@ const {
|
||||
}),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logError: (message, error) => logger.error(message, error),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||
clearSetupWindow: () => {
|
||||
appState.jellyfinSetupWindow = null;
|
||||
},
|
||||
@@ -3938,8 +4248,10 @@ const {
|
||||
registerSubminerProtocolClient,
|
||||
} = composeAnilistSetupHandlers({
|
||||
notifyDeps: {
|
||||
getNotificationType: () => getConfiguredStatusNotificationType(),
|
||||
hasMpvClient: () => Boolean(appState.mpvClient),
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||
showOverlayNotification,
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
@@ -4266,7 +4578,7 @@ const {
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
},
|
||||
showMpvOsd: (message) => showMpvOsd(message),
|
||||
showMpvOsd: (message) => showConfiguredStatusNotification(message, { title: 'AniList' }),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
minWatchSeconds: ANILIST_UPDATE_MIN_WATCH_SECONDS,
|
||||
@@ -4939,6 +5251,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -5017,7 +5331,7 @@ let signalAutoplayReadyFromWarmTokenization: ((path: string | null | undefined)
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
tokenizeSubtitle,
|
||||
tokenizeSubtitle: tokenizeSubtitleRuntime,
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
@@ -5040,6 +5354,7 @@ const {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5077,6 +5392,7 @@ const {
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (path) => {
|
||||
const normalizedPath = path.trim();
|
||||
maybeStartOverlayLoadingOsd(normalizedPath);
|
||||
const previousPath = appState.currentMediaPath?.trim() || null;
|
||||
const preserveParsedSubtitleCues = isSameYoutubeMediaPath(
|
||||
normalizedPath,
|
||||
@@ -5332,13 +5648,13 @@ const {
|
||||
ensureJlptDictionaryLookup: () => jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showMpvOsd: (message: string) => showConfiguredStatusNotification(message),
|
||||
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
|
||||
showLoadedOsd: (message: string) =>
|
||||
startupOsdSequencer.markAnnotationLoadingComplete(message),
|
||||
shouldShowOsdNotification: () => {
|
||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return type === 'osd' || type === 'both';
|
||||
const type = getConfiguredStatusNotificationType();
|
||||
return type === 'osd' || type === 'osd-system';
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5391,6 +5707,14 @@ const {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
startupOsdSequencer.showTokenizationLoading('Loading subtitle tokenization...');
|
||||
}
|
||||
return await tokenizeSubtitleRuntime(text);
|
||||
}
|
||||
|
||||
signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease({
|
||||
isTokenizationWarmupReady: () => isTokenizationWarmupReady(),
|
||||
startTokenizationWarmups: async () => {
|
||||
@@ -5424,6 +5748,7 @@ const aniSkipRuntime = createAniSkipRuntime({
|
||||
showMpvOsd: (text, durationMs) => {
|
||||
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
|
||||
},
|
||||
showPlaybackFeedback: (text) => showConfiguredPlaybackFeedback(text),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
@@ -5891,8 +6216,7 @@ 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);
|
||||
showConfiguredStatusNotification(message, { variant: 'warning' });
|
||||
return false;
|
||||
}
|
||||
openYomitanSettingsHandler();
|
||||
@@ -5979,7 +6303,7 @@ const {
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
@@ -6214,6 +6538,7 @@ function getUpdateService() {
|
||||
{ notificationType: getResolvedConfig().updates.notificationType, version },
|
||||
{
|
||||
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
|
||||
showOverlayNotification,
|
||||
showOsdNotification: (message) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
@@ -6238,7 +6563,7 @@ const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
broadcastToOverlayWindows: (channel, mode) => {
|
||||
broadcastToOverlayWindows(channel, mode);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||
});
|
||||
@@ -6275,7 +6600,7 @@ const buildUpdateLastCardFromClipboardMainDepsHandler =
|
||||
createBuildUpdateLastCardFromClipboardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
readClipboardText: () => clipboard.readText(),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
updateLastCardFromClipboardCore,
|
||||
});
|
||||
const updateLastCardFromClipboardMainDeps = buildUpdateLastCardFromClipboardMainDepsHandler();
|
||||
@@ -6294,7 +6619,7 @@ const refreshKnownWordCacheHandler = createRefreshKnownWordCacheHandler(
|
||||
|
||||
const buildTriggerFieldGroupingMainDepsHandler = createBuildTriggerFieldGroupingMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
triggerFieldGroupingCore,
|
||||
});
|
||||
const triggerFieldGroupingMainDeps = buildTriggerFieldGroupingMainDepsHandler();
|
||||
@@ -6303,7 +6628,7 @@ const triggerFieldGroupingHandler = createTriggerFieldGroupingHandler(triggerFie
|
||||
const buildMarkLastCardAsAudioCardMainDepsHandler =
|
||||
createBuildMarkLastCardAsAudioCardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
markLastCardAsAudioCardCore,
|
||||
});
|
||||
const markLastCardAsAudioCardMainDeps = buildMarkLastCardAsAudioCardMainDepsHandler();
|
||||
@@ -6314,7 +6639,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
|
||||
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6328,7 +6653,7 @@ const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||
const buildHandleMultiCopyDigitMainDepsHandler = createBuildHandleMultiCopyDigitMainDepsHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => clipboard.writeText(text),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
handleMultiCopyDigitCore,
|
||||
});
|
||||
const handleMultiCopyDigitMainDeps = buildHandleMultiCopyDigitMainDepsHandler();
|
||||
@@ -6337,7 +6662,7 @@ const handleMultiCopyDigitHandler = createHandleMultiCopyDigitHandler(handleMult
|
||||
const buildCopyCurrentSubtitleMainDepsHandler = createBuildCopyCurrentSubtitleMainDepsHandler({
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
writeClipboardText: (text) => clipboard.writeText(text),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
copyCurrentSubtitleCore,
|
||||
});
|
||||
const copyCurrentSubtitleMainDeps = buildCopyCurrentSubtitleMainDepsHandler();
|
||||
@@ -6348,7 +6673,7 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
||||
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getCurrentSecondarySubText: () => appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
@@ -6391,7 +6716,7 @@ const buildAppendClipboardVideoToQueueMainDepsHandler =
|
||||
appendClipboardVideoToQueueRuntime,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
readClipboardText: () => clipboard.readText(),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredStatusNotification(text),
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
@@ -6530,7 +6855,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
|
||||
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
|
||||
@@ -6556,6 +6881,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: async () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6587,12 +6913,12 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => showMpvOsd(text),
|
||||
(text) => showConfiguredPlaybackFeedback(text),
|
||||
);
|
||||
},
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6614,10 +6940,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => showMpvOsd(text),
|
||||
(text) => showConfiguredPlaybackFeedback(text),
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
@@ -6633,7 +6960,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
registration: {
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
mainDeps: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -6703,6 +7030,30 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
linuxOverlayInteractiveHint = interactive;
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement();
|
||||
},
|
||||
handleOverlayNotificationAction: (notificationId, actionId, noteId) => {
|
||||
if (
|
||||
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
|
||||
actionId === INSTALL_UPDATE_ACTION_ID
|
||||
) {
|
||||
void getUpdateService()
|
||||
.checkForUpdates({
|
||||
source: 'manual',
|
||||
installWhenAvailable: true,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to install update from overlay notification action:', error);
|
||||
});
|
||||
}
|
||||
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||
showConfiguredStatusNotification('Failed to open Anki card in Anki.', {
|
||||
id: 'open-anki-card-failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
|
||||
@@ -6714,9 +7065,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
currentSubText: appState.currentSubText,
|
||||
currentSubtitleData: appState.currentSubtitleData,
|
||||
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||
? (text) => tokenizeSubtitleForCurrent(text)
|
||||
: undefined,
|
||||
onResolvedSubtitle: (payload) => {
|
||||
appState.currentSubtitleData = payload;
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(payload, { forceWhilePaused: true });
|
||||
},
|
||||
});
|
||||
},
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
@@ -6840,6 +7196,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
|
||||
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
|
||||
getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition,
|
||||
getControllerConfig: () => getResolvedConfig().controller,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = configService.getRawConfig();
|
||||
@@ -6862,7 +7219,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
reportOverlayContentBounds: (payload: unknown) => {
|
||||
if (overlayContentMeasurementStore.report(payload)) {
|
||||
tickLinuxOverlayPointerInteractionNow();
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
}
|
||||
},
|
||||
getAnilistStatus: () => anilistStateRuntime.getStatusSnapshot(),
|
||||
@@ -6970,6 +7329,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||
getFieldGroupingResolver: () => getFieldGroupingResolver(),
|
||||
@@ -7006,7 +7366,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) =>
|
||||
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
|
||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
|
||||
@@ -7231,11 +7592,14 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
linuxVisibleOverlayWindowMode === 'fullscreen-override',
|
||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||
onVisibleWindowFocused: () => requestLinuxOverlayZOrderFollow(),
|
||||
onWindowDidFinishLoad: () => {
|
||||
flushQueuedOverlayNotifications();
|
||||
},
|
||||
onWindowContentReady: () => {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
flushQueuedOverlayNotifications();
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
if (appState.currentSubText.trim()) {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
}
|
||||
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
},
|
||||
onWindowClosed: (windowKind, window) => {
|
||||
@@ -7274,7 +7638,8 @@ function getJellyfinTrayDiscoveryDeps() {
|
||||
startRemoteSession: (options: { explicit: true }) => startJellyfinRemoteSession(options),
|
||||
refreshTrayMenu: () => refreshTrayMenuIfPresent(),
|
||||
logger,
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showMpvOsd: (message: string) =>
|
||||
showConfiguredStatusNotification(message, { title: 'Jellyfin' }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7421,6 +7786,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
showOverlayNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
@@ -7512,11 +7878,15 @@ function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7530,10 +7900,14 @@ function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
if (!nextVisible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
resetVisibleOverlayInputState();
|
||||
} else {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
@@ -7544,11 +7918,15 @@ function toggleVisibleOverlay(): void {
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
dismissOverlayLoadingStatusNotification();
|
||||
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
|
||||
resetVisibleOverlayInputState();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||
restoreVisibleOverlayWindowShapeForShow();
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
void primeCurrentSubtitleForVisibleOverlay();
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
|
||||
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
||||
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: AppReadyRuntimeDeps['shouldHandleInitialArgsBeforeDeferredOverlayWarmup'];
|
||||
}
|
||||
|
||||
export function createAppLifecycleRuntimeDeps(
|
||||
@@ -133,6 +134,8 @@ export function createAppReadyRuntimeDeps(
|
||||
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
|
||||
params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient'];
|
||||
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
|
||||
showPlaybackFeedback?: CliCommandRuntimeServiceDepsParams['mpv']['showPlaybackFeedback'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
getTexthookerWebsocketUrl: () => string | undefined;
|
||||
@@ -74,6 +75,7 @@ function createCliCommandDepsFromContext(
|
||||
setSocketPath: context.setSocketPath,
|
||||
getClient: context.getClient,
|
||||
showOsd: context.showOsd,
|
||||
showPlaybackFeedback: context.showPlaybackFeedback,
|
||||
},
|
||||
texthooker: {
|
||||
service: context.texthookerService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
|
||||
import type { OverlayNotificationPayload } from '../types/notification';
|
||||
import { SubsyncResolvedConfig } from '../subsync/utils';
|
||||
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
|
||||
import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
|
||||
@@ -59,6 +60,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -82,6 +84,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
|
||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
||||
@@ -124,6 +127,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
|
||||
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
|
||||
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
|
||||
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
|
||||
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
|
||||
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
|
||||
@@ -145,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath'];
|
||||
getClient: CliCommandDepsRuntimeOptions['mpv']['getClient'];
|
||||
showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd'];
|
||||
showPlaybackFeedback?: CliCommandDepsRuntimeOptions['mpv']['showPlaybackFeedback'];
|
||||
};
|
||||
texthooker: {
|
||||
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
|
||||
@@ -221,6 +226,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||
@@ -240,6 +246,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
@@ -261,6 +268,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
getStatsToggleKey: params.getStatsToggleKey,
|
||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
saveControllerConfig: params.saveControllerConfig,
|
||||
saveControllerPreference: params.saveControllerPreference,
|
||||
@@ -309,6 +317,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
|
||||
setAnkiIntegration: params.setAnkiIntegration,
|
||||
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
|
||||
showDesktopNotification: params.showDesktopNotification,
|
||||
showOverlayNotification: params.showOverlayNotification,
|
||||
createFieldGroupingCallback: params.createFieldGroupingCallback,
|
||||
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
|
||||
getFieldGroupingResolver: params.getFieldGroupingResolver,
|
||||
@@ -334,6 +343,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
setSocketPath: params.mpv.setSocketPath,
|
||||
getClient: params.mpv.getClient,
|
||||
showOsd: params.mpv.showOsd,
|
||||
showPlaybackFeedback: params.mpv.showPlaybackFeedback,
|
||||
},
|
||||
texthooker: {
|
||||
service: params.texthooker.service,
|
||||
@@ -414,6 +424,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
showPlaybackFeedback: params.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||
@@ -41,6 +42,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
|
||||
+163
-12
@@ -59,6 +59,50 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv startup signals start overlay loading OSD before readiness work', () => {
|
||||
const source = readMainSource();
|
||||
const connectedBlock = source.match(
|
||||
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
|
||||
)?.groups?.body;
|
||||
const mediaPathBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
const setVisibleBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(connectedBlock);
|
||||
assert.ok(mediaPathBlock);
|
||||
assert.ok(setVisibleBlock);
|
||||
assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/);
|
||||
assert.match(
|
||||
mediaPathBlock,
|
||||
/const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/,
|
||||
);
|
||||
assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||
const source = readMainSource();
|
||||
const dismissBlock = source.match(
|
||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(dismissBlock);
|
||||
assert.match(
|
||||
dismissBlock,
|
||||
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
||||
assert.ok(setOverlayBlock);
|
||||
assert.match(
|
||||
setVisibleBlock,
|
||||
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
/if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
|
||||
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
setOverlayBlock,
|
||||
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,6 +162,23 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
|
||||
);
|
||||
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
||||
assert.match(source, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/);
|
||||
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
@@ -160,7 +221,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
||||
);
|
||||
});
|
||||
|
||||
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
|
||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||
const source = readMainSource();
|
||||
const gateBlock = source.match(
|
||||
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
@@ -171,7 +232,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
||||
|
||||
assert.ok(gateBlock);
|
||||
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
|
||||
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
|
||||
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
|
||||
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
|
||||
|
||||
@@ -180,6 +241,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
});
|
||||
|
||||
test('visible overlay content-ready does not tokenize before first measurement', () => {
|
||||
const source = readMainSource();
|
||||
const contentReadyBlock = source.match(
|
||||
/onWindowContentReady:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const measurementBlock = source.match(
|
||||
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(contentReadyBlock);
|
||||
assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/);
|
||||
assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') <
|
||||
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
assert.ok(
|
||||
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') <
|
||||
contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'),
|
||||
);
|
||||
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
|
||||
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') <
|
||||
measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
|
||||
const source = readMainSource();
|
||||
const measurementBlock = source.match(
|
||||
@@ -189,10 +281,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
|
||||
assert.ok(measurementBlock);
|
||||
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
|
||||
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
|
||||
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
|
||||
);
|
||||
assert.ok(
|
||||
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
|
||||
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
|
||||
@@ -216,11 +313,14 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
|
||||
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
|
||||
});
|
||||
|
||||
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
|
||||
test('warm tokenization release can signal readiness before the first subtitle appears', () => {
|
||||
const source = readMainSource();
|
||||
const warmReleaseBlock = source.match(
|
||||
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||
)?.groups?.body;
|
||||
const signalBlock = source.match(
|
||||
/function signalCurrentSubtitleAutoplayReady\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const currentPayloadBlock = source.match(
|
||||
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
@@ -230,7 +330,12 @@ test('warm tokenization release reuses current subtitle payload instead of synth
|
||||
warmReleaseBlock,
|
||||
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
|
||||
|
||||
assert.ok(signalBlock);
|
||||
assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/);
|
||||
assert.match(signalBlock, /if \(payload\) \{/);
|
||||
assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/);
|
||||
assert.match(signalBlock, /text: '__warm__'/);
|
||||
|
||||
assert.ok(currentPayloadBlock);
|
||||
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
|
||||
@@ -247,7 +352,10 @@ test('stats server Yomitan note creation honors configured Anki server override
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
||||
assert.match(
|
||||
addYomitanNoteBlock,
|
||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
||||
);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||
});
|
||||
@@ -321,6 +429,49 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
@@ -334,11 +485,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
toggleBlock,
|
||||
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -357,7 +508,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||
assert.match(
|
||||
setBlock,
|
||||
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
dismissOverlayLoadingOsd?: () => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
|
||||
}
|
||||
@@ -80,6 +81,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
|
||||
shouldShowOverlayLoadingOsd: () =>
|
||||
lastOverlayLoadingOsdAtMs === null ||
|
||||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
|
||||
|
||||
@@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
const mediaKey = '/tmp/video.mkv';
|
||||
@@ -378,5 +378,5 @@ test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after ret
|
||||
assert.equal(calls.includes('update'), false);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
|
||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||
});
|
||||
|
||||
@@ -194,8 +194,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.processNextAnilistRetryUpdate();
|
||||
const retryResult = await deps.processNextAnilistRetryUpdate();
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
if (retryResult.ok) {
|
||||
deps.showMpvOsd(retryResult.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,18 @@ test('notify anilist setup main deps builder maps callbacks', () => {
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
|
||||
|
||||
@@ -18,8 +18,12 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
|
||||
|
||||
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
|
||||
return (): NotifyAnilistSetupMainDeps => ({
|
||||
getNotificationType: deps.getNotificationType ? () => deps.getNotificationType?.() : undefined,
|
||||
hasMpvClient: () => deps.hasMpvClient(),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
showOverlayNotification: deps.showOverlayNotification
|
||||
? (payload) => deps.showOverlayNotification?.(payload)
|
||||
: undefined,
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
|
||||
@@ -19,6 +19,24 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
|
||||
assert.deepEqual(calls, ['osd:AniList login success']);
|
||||
});
|
||||
|
||||
test('createNotifyAnilistSetupHandler routes through configured notification surfaces', () => {
|
||||
const calls: string[] = [];
|
||||
const notify = createNotifyAnilistSetupHandler({
|
||||
getNotificationType: () => 'both',
|
||||
hasMpvClient: () => true,
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
logInfo: () => calls.push('log'),
|
||||
});
|
||||
notify('AniList login success');
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:SubMiner AniList:AniList login success:success',
|
||||
'notify:SubMiner AniList:AniList login success',
|
||||
]);
|
||||
});
|
||||
|
||||
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
|
||||
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
|
||||
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||
|
||||
export type ConsumeAnilistSetupTokenDeps = {
|
||||
consumeAnilistSetupCallbackUrl: (input: {
|
||||
rawUrl: string;
|
||||
@@ -30,12 +32,35 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
|
||||
}
|
||||
|
||||
export function createNotifyAnilistSetupHandler(deps: {
|
||||
getNotificationType?: () => NotificationType | undefined;
|
||||
hasMpvClient: () => boolean;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
logInfo: (message: string) => void;
|
||||
}) {
|
||||
return (message: string): void => {
|
||||
const type = deps.getNotificationType?.();
|
||||
if (type) {
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
deps.showOverlayNotification?.({
|
||||
title: 'SubMiner AniList',
|
||||
body: message,
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
if ((type === 'osd' || type === 'osd-system') && deps.hasMpvClient()) {
|
||||
deps.showMpvOsd(message);
|
||||
}
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
deps.showDesktopNotification('SubMiner AniList', { body: message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.hasMpvClient()) {
|
||||
deps.showMpvOsd(message);
|
||||
return;
|
||||
|
||||
@@ -22,19 +22,21 @@ function createHarness(options?: {
|
||||
buttonKey?: string;
|
||||
metadata?: AniSkipMetadata | (() => Promise<AniSkipMetadata>);
|
||||
chapterList?: unknown;
|
||||
playbackFeedback?: boolean;
|
||||
}) {
|
||||
const state = {
|
||||
enabled: options?.enabled ?? true,
|
||||
buttonKey: options?.buttonKey ?? 'TAB',
|
||||
commands: [] as unknown[][],
|
||||
osd: [] as string[],
|
||||
feedback: [] as string[],
|
||||
resolveCalls: [] as string[],
|
||||
connected: true,
|
||||
timePos: 0,
|
||||
chapterList: options?.chapterList ?? [],
|
||||
};
|
||||
|
||||
const deps: AniSkipRuntimeDeps = {
|
||||
const deps = {
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: state.enabled,
|
||||
aniskipButtonKey: state.buttonKey,
|
||||
@@ -57,10 +59,17 @@ function createHarness(options?: {
|
||||
showMpvOsd: (text) => {
|
||||
state.osd.push(text);
|
||||
},
|
||||
...(options?.playbackFeedback
|
||||
? {
|
||||
showPlaybackFeedback: (text: string) => {
|
||||
state.feedback.push(text);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
};
|
||||
} satisfies AniSkipRuntimeDeps & { showPlaybackFeedback?: (text: string) => void };
|
||||
|
||||
return { runtime: createAniSkipRuntime(deps), state };
|
||||
}
|
||||
@@ -152,6 +161,19 @@ test('time-pos prompt shows once near intro start', async () => {
|
||||
assert.deepEqual(state.osd, ['You can skip by pressing TAB']);
|
||||
});
|
||||
|
||||
test('prompt and skip messages use playback feedback when configured', async () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB', playbackFeedback: true });
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
runtime.handleTimePosChange({ time: 10.5 });
|
||||
state.timePos = 30;
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
|
||||
assert.deepEqual(state.feedback, ['You can skip by pressing TAB', 'Skipped intro']);
|
||||
assert.deepEqual(state.osd, []);
|
||||
});
|
||||
|
||||
test('connection change binds skip key and legacy fallback for custom keys', () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'F6' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AniSkipRuntimeDeps {
|
||||
isMpvConnected: () => boolean;
|
||||
getCurrentTimePos: () => number;
|
||||
showMpvOsd: (text: string, durationMs: number) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
logDebug: (message: string) => void;
|
||||
@@ -53,6 +54,14 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||
return key || DEFAULT_ANISKIP_BUTTON_KEY;
|
||||
}
|
||||
|
||||
function showPlaybackFeedback(text: string, durationMs = PROMPT_OSD_DURATION_MS): void {
|
||||
if (deps.showPlaybackFeedback) {
|
||||
deps.showPlaybackFeedback(text);
|
||||
return;
|
||||
}
|
||||
deps.showMpvOsd(text, durationMs);
|
||||
}
|
||||
|
||||
function bindSkipKeys(): void {
|
||||
if (!deps.isMpvConnected()) return;
|
||||
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||
@@ -204,23 +213,23 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||
function skipIntroNow(): void {
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
if (!introWindow) {
|
||||
deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
showPlaybackFeedback('Intro skip unavailable');
|
||||
return;
|
||||
}
|
||||
const now = deps.getCurrentTimePos();
|
||||
if (!Number.isFinite(now)) {
|
||||
deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
showPlaybackFeedback('Skip unavailable');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS ||
|
||||
now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS
|
||||
) {
|
||||
deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS);
|
||||
showPlaybackFeedback('Skip intro only during intro');
|
||||
return;
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]);
|
||||
deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS);
|
||||
showPlaybackFeedback('Skipped intro');
|
||||
}
|
||||
|
||||
function handleTimePosChange({ time }: { time: number }): void {
|
||||
@@ -229,7 +238,7 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||
const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end);
|
||||
if (time >= introWindow.start && time < promptWindowEnd) {
|
||||
promptShown = true;
|
||||
deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS);
|
||||
showPlaybackFeedback(`You can skip by pressing ${resolveButtonKey()}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
startBackgroundWarmups: () => calls.push('start-warmups'),
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
|
||||
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
handleInitialArgs: () => calls.push('handle-initial-args'),
|
||||
@@ -64,6 +65,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
assert.equal(onReady.defaultTexthookerPort, 5174);
|
||||
assert.equal(onReady.texthookerOnlyMode, false);
|
||||
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
|
||||
assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true);
|
||||
assert.equal(onReady.now?.(), 123);
|
||||
onReady.loadSubtitlePosition();
|
||||
onReady.resolveKeybindings();
|
||||
|
||||
@@ -45,5 +45,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
|
||||
deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,6 +314,100 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate retries deferred readiness without an external flush event', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let targetReady = false;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => targetReady,
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
targetReady = true;
|
||||
scheduled.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'script-message'),
|
||||
[['script-message', 'subminer-autoplay-ready']],
|
||||
);
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
isSignalTargetReady: () => false,
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
for (let attempt = 1; attempt <= 100; attempt += 1) {
|
||||
assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
|
||||
scheduled.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
|
||||
|
||||
const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200;
|
||||
const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150;
|
||||
|
||||
type MpvClientLike = {
|
||||
connected?: boolean;
|
||||
requestProperty: (property: string) => Promise<unknown>;
|
||||
@@ -34,12 +37,22 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
let autoPlayReadySignalMediaPath: string | null = null;
|
||||
let autoPlayReadySignalGeneration = 0;
|
||||
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
|
||||
let pendingAutoplayReadyRetryToken = 0;
|
||||
let pendingAutoplayReadyRetryAttempts = 0;
|
||||
let scheduledPendingAutoplayReadyRetryToken: number | null = null;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
const invalidatePendingAutoplayReadyRetry = (): void => {
|
||||
pendingAutoplayReadyRetryToken += 1;
|
||||
pendingAutoplayReadyRetryAttempts = 0;
|
||||
scheduledPendingAutoplayReadyRetryToken = null;
|
||||
};
|
||||
|
||||
const invalidatePendingAutoplayReadyFallbacks = (): void => {
|
||||
autoPlayReadySignalMediaPath = null;
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
invalidatePendingAutoplayReadyRetry();
|
||||
};
|
||||
|
||||
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
|
||||
@@ -52,18 +65,43 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
invalidatePendingAutoplayReadyRetry();
|
||||
};
|
||||
|
||||
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => {
|
||||
if (
|
||||
pendingAutoplayReadySignal &&
|
||||
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
|
||||
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
|
||||
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
pendingAutoplayReadySignal = signal;
|
||||
pendingAutoplayReadyRetryAttempts = 0;
|
||||
return true;
|
||||
};
|
||||
|
||||
const schedulePendingAutoplayReadyRetry = (): void => {
|
||||
if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) {
|
||||
return;
|
||||
}
|
||||
if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryToken = pendingAutoplayReadyRetryToken;
|
||||
pendingAutoplayReadyRetryAttempts += 1;
|
||||
scheduledPendingAutoplayReadyRetryToken = retryToken;
|
||||
deps.schedule(() => {
|
||||
if (scheduledPendingAutoplayReadyRetryToken === retryToken) {
|
||||
scheduledPendingAutoplayReadyRetryToken = null;
|
||||
}
|
||||
if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) {
|
||||
return;
|
||||
}
|
||||
flushPendingAutoplayReadySignal();
|
||||
}, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
|
||||
@@ -139,6 +177,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
};
|
||||
|
||||
pendingAutoplayReadySignal = null;
|
||||
invalidatePendingAutoplayReadyRetry();
|
||||
autoPlayReadySignalMediaPath = mediaPath;
|
||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||
deps.signalPluginAutoplayReady();
|
||||
@@ -152,10 +191,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
if (!isSignalTargetReady(signal)) {
|
||||
setPendingAutoplayReadySignal(signal);
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
|
||||
);
|
||||
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
|
||||
schedulePendingAutoplayReadyRetry();
|
||||
if (pendingSignalChanged) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
notifyCharacterDictionaryAutoSyncStatus,
|
||||
type CharacterDictionaryAutoSyncNotificationEvent,
|
||||
} from './character-dictionary-auto-sync-notifications';
|
||||
import { createStartupOsdSequencer } from './startup-osd-sequencer';
|
||||
|
||||
function makeEvent(
|
||||
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||
@@ -70,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync notifications never send desktop notifications', () => {
|
||||
test('auto sync notifications send overlay and desktop delivery for both', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
@@ -80,14 +81,10 @@ test('auto sync notifications never send desktop notifications', () => {
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'both',
|
||||
@@ -96,9 +93,25 @@ test('auto sync notifications never send desktop notifications', () => {
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
|
||||
getNotificationType: () => 'both',
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin',
|
||||
'desktop:SubMiner:syncing',
|
||||
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto',
|
||||
'desktop:SubMiner:ready',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync notifications fall back to desktop when overlay routing is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('building', 'building'), {
|
||||
getNotificationType: () => undefined,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
@@ -106,14 +119,30 @@ 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, ['desktop:SubMiner:building']);
|
||||
});
|
||||
|
||||
test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => {
|
||||
test('auto sync notifications keep osd-system on legacy surfaces', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:syncing', 'desktop:SubMiner:syncing']);
|
||||
});
|
||||
|
||||
test('auto sync notifications keep osd-system desktop delivery even when osd is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||
getNotificationType: () => 'both',
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
return false;
|
||||
@@ -122,7 +151,7 @@ test('auto sync notifications fall back to desktop for long progress when osd is
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||
getNotificationType: () => 'both',
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
return false;
|
||||
@@ -131,14 +160,19 @@ test('auto sync notifications fall back to desktop for long progress when osd is
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']);
|
||||
assert.deepEqual(calls, [
|
||||
'osd:generating',
|
||||
'desktop:SubMiner:generating',
|
||||
'osd:ready',
|
||||
'desktop:SubMiner:ready',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => {
|
||||
test('auto sync notifications send osd-system desktop updates with startup sequencer', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'both',
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
@@ -154,3 +188,29 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot
|
||||
|
||||
assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']);
|
||||
});
|
||||
|
||||
test('auto sync notifications let startup sequencer own osd-system desktop delivery', () => {
|
||||
const calls: string[] = [];
|
||||
const startupOsdSequencer = createStartupOsdSequencer({
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) => {
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`);
|
||||
},
|
||||
});
|
||||
startupOsdSequencer.markTokenizationReady();
|
||||
|
||||
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`direct-osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`direct-desktop:${title}:${options.body ?? ''}`),
|
||||
startupOsdSequencer,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
|
||||
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
|
||||
|
||||
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
|
||||
|
||||
export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
startupOsdSequencer?: {
|
||||
notifyCharacterDictionaryStatus: (
|
||||
@@ -14,39 +17,58 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
|
||||
return type !== 'none';
|
||||
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
|
||||
return phase === 'ready' || phase === 'failed';
|
||||
}
|
||||
|
||||
function shouldFallbackToDesktop(
|
||||
type: 'osd' | 'system' | 'both' | 'none' | undefined,
|
||||
function overlayVariantForPhase(
|
||||
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||
): boolean {
|
||||
return (
|
||||
(type === 'system' || type === 'both') &&
|
||||
(phase === 'generating' || phase === 'building' || phase === 'importing')
|
||||
);
|
||||
): OverlayNotificationPayload['variant'] {
|
||||
if (phase === 'ready') return 'success';
|
||||
if (phase === 'failed') return 'error';
|
||||
return 'progress';
|
||||
}
|
||||
|
||||
function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string {
|
||||
const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current';
|
||||
return `character-dictionary-auto-sync-${mediaId}-${event.phase}`;
|
||||
}
|
||||
|
||||
export function notifyCharacterDictionaryAutoSyncStatus(
|
||||
event: CharacterDictionaryAutoSyncNotificationEvent,
|
||||
deps: CharacterDictionaryAutoSyncNotificationDeps,
|
||||
): void {
|
||||
const type = deps.getNotificationType();
|
||||
if (shouldShowOsd(type)) {
|
||||
if (deps.startupOsdSequencer) {
|
||||
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||
phase: event.phase,
|
||||
message: event.message,
|
||||
const type = deps.getNotificationType() ?? 'overlay';
|
||||
if (type === 'none') return;
|
||||
let startupSequencerShown = false;
|
||||
|
||||
if (shouldShowOverlay(type)) {
|
||||
if (deps.showOverlayNotification) {
|
||||
deps.showOverlayNotification({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: historyIdForEvent(event),
|
||||
title: 'Character dictionary',
|
||||
body: event.message,
|
||||
variant: overlayVariantForPhase(event.phase),
|
||||
persistent: !isTerminalPhase(event.phase),
|
||||
});
|
||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const shown = deps.showOsd(event.message) !== false;
|
||||
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
|
||||
} else if (!shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowOsd(type)) {
|
||||
if (deps.startupOsdSequencer) {
|
||||
startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||
phase: event.phase,
|
||||
message: event.message,
|
||||
});
|
||||
} else {
|
||||
deps.showOsd(event.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowDesktop(type) && !startupSequencerShown) {
|
||||
deps.showDesktopNotification('SubMiner', { body: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ function makeDeps(options: {
|
||||
getNotificationType: () => options.notificationType ?? 'osd',
|
||||
openManager: () => calls.push('open'),
|
||||
showOsd: (message: string) => calls.push(`osd:${message}`),
|
||||
showOverlayNotification: (payload: { title: string; body?: string }) =>
|
||||
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title: string, opts: { body: string }) =>
|
||||
calls.push(`system:${title}:${opts.body}`),
|
||||
logWarn: (message: string) => calls.push(`warn:${message}`),
|
||||
@@ -39,6 +41,13 @@ test('routes disabled manager notification to configured surfaces', () => {
|
||||
['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
|
||||
[
|
||||
'both',
|
||||
[
|
||||
`overlay:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
|
||||
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
|
||||
],
|
||||
],
|
||||
[
|
||||
'osd-system',
|
||||
[
|
||||
`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
|
||||
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||
|
||||
export type CharacterDictionaryManagerNotificationType = NotificationType;
|
||||
|
||||
export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE =
|
||||
'Enable Name Match in Settings to use the character dictionary manager.';
|
||||
@@ -8,16 +10,27 @@ export interface CharacterDictionaryManagerGateDeps {
|
||||
getNotificationType: () => CharacterDictionaryManagerNotificationType;
|
||||
openManager: () => void;
|
||||
showOsd: (message: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
logWarn?: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void {
|
||||
const type = deps.getNotificationType();
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
deps.showOverlayNotification?.({
|
||||
title: 'SubMiner',
|
||||
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE);
|
||||
}
|
||||
if (type === 'system' || type === 'both') {
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
try {
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
|
||||
showOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
@@ -63,6 +64,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
setSocketPath: deps.setSocketPath,
|
||||
getMpvClient: deps.getMpvClient,
|
||||
showOsd: deps.showOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: deps.getTexthookerPort,
|
||||
setTexthookerPort: deps.setTexthookerPort,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
},
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
showOsd: (text: string) => deps.showMpvOsd(text),
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: () => deps.appState.texthookerPort,
|
||||
setTexthookerPort: (port: number) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
getMpvClient: () => MpvClientLike;
|
||||
showOsd: (text: string) => void;
|
||||
showPlaybackFeedback?: (text: string) => void;
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
getTexthookerPort: () => number;
|
||||
setTexthookerPort: (port: number) => void;
|
||||
@@ -72,6 +73,7 @@ export function createCliCommandContext(
|
||||
setSocketPath: deps.setSocketPath,
|
||||
getClient: deps.getMpvClient,
|
||||
showOsd: deps.showOsd,
|
||||
showPlaybackFeedback: deps.showPlaybackFeedback,
|
||||
texthookerService: deps.texthookerService,
|
||||
getTexthookerPort: deps.getTexthookerPort,
|
||||
setTexthookerPort: deps.setTexthookerPort,
|
||||
|
||||
@@ -58,6 +58,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getOverlayNotificationPosition: () => 'top-right',
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
|
||||
@@ -265,6 +265,23 @@ test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop not
|
||||
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadMessageHandler routes message through configured notification surfaces', () => {
|
||||
const calls: string[] = [];
|
||||
const handleMessage = createConfigHotReloadMessageHandler({
|
||||
getNotificationType: () => 'both',
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
});
|
||||
|
||||
handleMessage('Config reload failed');
|
||||
assert.deepEqual(calls, [
|
||||
'overlay:SubMiner:Config reload failed:warning',
|
||||
'notify:SubMiner:Config reload failed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildRestartRequiredConfigMessage formats changed fields', () => {
|
||||
assert.equal(
|
||||
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { AnkiConnectConfig } from '../../types/anki';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
@@ -26,7 +27,9 @@ type ConfigHotReloadAppliedDeps = {
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
getNotificationType?: () => NotificationType | undefined;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
};
|
||||
|
||||
@@ -183,8 +186,23 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
|
||||
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
|
||||
return (message: string): void => {
|
||||
deps.showMpvOsd(message);
|
||||
deps.showDesktopNotification('SubMiner', { body: message });
|
||||
const type = deps.getNotificationType?.() ?? 'osd-system';
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
if (type === 'overlay' || type === 'both') {
|
||||
deps.showOverlayNotification?.({
|
||||
title: 'SubMiner',
|
||||
body: message,
|
||||
variant: 'warning',
|
||||
});
|
||||
}
|
||||
if (type === 'osd' || type === 'osd-system') {
|
||||
deps.showMpvOsd(message);
|
||||
}
|
||||
if (type === 'system' || type === 'both' || type === 'osd-system') {
|
||||
deps.showDesktopNotification('SubMiner', { body: message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
deps: ConfigHotReloadMessageMainDeps,
|
||||
) {
|
||||
return (): ConfigHotReloadMessageMainDeps => ({
|
||||
getNotificationType: () => deps.getNotificationType?.(),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload),
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getPlaybackFeedbackNotificationOptions,
|
||||
notifyConfiguredStatus,
|
||||
} from './configured-status-notification';
|
||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||
|
||||
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Subsync: choose engine and source', {
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
|
||||
'desktop:SubMiner:Subsync: choose engine and source',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
getNotificationType: () => 'both',
|
||||
isOverlayReady: () => false,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
getNotificationType: () => 'overlay',
|
||||
isOverlayReady: () => false,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
getNotificationType: () => 'system',
|
||||
isOverlayReady: () => false,
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay loading...', {
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus(
|
||||
'Subsync: syncing |',
|
||||
{
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(
|
||||
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
},
|
||||
{
|
||||
id: 'subsync-status',
|
||||
title: 'Subsync',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
desktop: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus(
|
||||
'Primary subtitle: hover',
|
||||
{
|
||||
getNotificationType: () => 'both',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showOverlayNotification: (payload) =>
|
||||
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
},
|
||||
{ delivery: 'feedback' },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['overlay:SubMiner:Primary subtitle: hover']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus routes osd-system feedback through osd only', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus(
|
||||
'Secondary subtitle: visible',
|
||||
{
|
||||
getNotificationType: () => 'osd-system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
},
|
||||
{ delivery: 'feedback' },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['osd:Secondary subtitle: visible']);
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus suppresses system-only feedback', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus(
|
||||
'Primary subtitle: visible',
|
||||
{
|
||||
getNotificationType: () => 'system',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
},
|
||||
{ delivery: 'feedback' },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('playback feedback options reuse subtitle mode notification ids', () => {
|
||||
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Primary subtitle: hover'), {
|
||||
id: 'primary-subtitle-mode-feedback',
|
||||
});
|
||||
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle: hidden'), {
|
||||
id: 'secondary-subtitle-mode-feedback',
|
||||
});
|
||||
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
|
||||
});
|
||||
|
||||
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
notifyConfiguredStatus('Overlay unavailable.', {
|
||||
getNotificationType: () => 'overlay',
|
||||
showOsd: (message) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery queues until an overlay window is ready', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 2);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, [
|
||||
'startup-tokenization:Loading',
|
||||
'character-dictionary-auto-sync:Building',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery upserts queued progress by notification id', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
|
||||
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery preserves queued events with distinct history ids', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: 'character-dictionary-auto-sync-checking',
|
||||
title: 'Character dictionary',
|
||||
body: 'Checking character dictionary...',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'character-dictionary-auto-sync',
|
||||
historyId: 'character-dictionary-auto-sync-building',
|
||||
title: 'Character dictionary',
|
||||
body: 'Building character dictionary...',
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
|
||||
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
scheduleFlushRetry: (callback) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'startup-tokenization',
|
||||
title: 'Subtitle tokenization',
|
||||
body: 'Loading subtitle tokenization...',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'startup-tokenization',
|
||||
title: 'Subtitle tokenization',
|
||||
body: 'Subtitle tokenization ready',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
'startup-tokenization:Loading subtitle tokenization...:pin',
|
||||
'startup-tokenization:Subtitle tokenization ready:auto',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const delays: number[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push(
|
||||
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
|
||||
),
|
||||
scheduleFlushRetry: (callback, delayMs) => {
|
||||
scheduled.push(callback);
|
||||
delays.push(delayMs);
|
||||
},
|
||||
terminalUpdateDelayMs: 750,
|
||||
});
|
||||
|
||||
delivery.send({
|
||||
id: 'startup-subtitle-annotations',
|
||||
title: 'Subtitle annotations',
|
||||
body: 'Loading subtitle annotations |',
|
||||
variant: 'progress',
|
||||
persistent: true,
|
||||
});
|
||||
delivery.send({
|
||||
id: 'startup-subtitle-annotations',
|
||||
title: 'Subtitle annotations',
|
||||
body: 'Subtitle annotations loaded',
|
||||
variant: 'success',
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
|
||||
assert.equal(delivery.getQueuedCount(), 1);
|
||||
assert.deepEqual(delays, [750]);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, [
|
||||
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
|
||||
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
|
||||
const sent: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
|
||||
scheduleFlushRetry: (callback) => {
|
||||
scheduled.push(callback);
|
||||
},
|
||||
});
|
||||
|
||||
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
|
||||
delivery.flush();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 1);
|
||||
assert.equal(scheduled.length, 1);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
ready = true;
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(delivery.getQueuedCount(), 0);
|
||||
assert.deepEqual(sent, ['startup-tokenization:Loading']);
|
||||
});
|
||||
|
||||
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||
|
||||
ready = true;
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
|
||||
const sent: string[] = [];
|
||||
let ready = false;
|
||||
const delivery = createOverlayNotificationDelivery({
|
||||
hasReadyOverlayWindow: () => ready,
|
||||
send: (payload) =>
|
||||
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
|
||||
});
|
||||
|
||||
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
|
||||
|
||||
ready = true;
|
||||
delivery.send({ id: 'overlay-loading-status', dismiss: true });
|
||||
delivery.flush();
|
||||
|
||||
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
|
||||
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
|
||||
|
||||
export interface ConfiguredStatusNotificationDeps {
|
||||
getNotificationType: () => NotificationType | undefined;
|
||||
isOverlayReady?: () => boolean;
|
||||
showOsd: (message: string) => boolean | void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
}
|
||||
|
||||
export interface ConfiguredStatusNotificationOptions {
|
||||
id?: string;
|
||||
title?: string;
|
||||
variant?: OverlayNotificationPayload['variant'];
|
||||
persistent?: boolean;
|
||||
desktop?: boolean;
|
||||
delivery?: 'notification' | 'feedback';
|
||||
}
|
||||
|
||||
export function getPlaybackFeedbackNotificationOptions(
|
||||
message: string,
|
||||
): ConfiguredStatusNotificationOptions {
|
||||
if (/^Primary subtitle: (hidden|visible|hover)$/.test(message)) {
|
||||
return { id: 'primary-subtitle-mode-feedback' };
|
||||
}
|
||||
if (/^Secondary subtitle: (hidden|visible|hover)$/.test(message)) {
|
||||
return { id: 'secondary-subtitle-mode-feedback' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function notifyConfiguredStatus(
|
||||
message: string,
|
||||
deps: ConfiguredStatusNotificationDeps,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
): void {
|
||||
const type = deps.getNotificationType() ?? 'overlay';
|
||||
const delivery = options.delivery ?? 'notification';
|
||||
const showOverlay = shouldShowOverlay(type);
|
||||
const showOsd = shouldShowOsd(type);
|
||||
const desktopEnabled = delivery !== 'feedback' && options.desktop !== false;
|
||||
|
||||
if (type === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (delivery === 'feedback' && !showOverlay && !showOsd) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showOverlay) {
|
||||
const overlayReady = deps.isOverlayReady?.() ?? true;
|
||||
if (deps.showOverlayNotification && overlayReady) {
|
||||
deps.showOverlayNotification({
|
||||
id: options.id,
|
||||
title: options.title ?? 'SubMiner',
|
||||
body: message,
|
||||
variant: options.variant ?? 'info',
|
||||
persistent: options.persistent ?? false,
|
||||
});
|
||||
} else if (desktopEnabled && !shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
|
||||
if (showOsd) {
|
||||
deps.showOsd(message);
|
||||
}
|
||||
|
||||
if (desktopEnabled && shouldShowDesktop(type)) {
|
||||
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,40 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
|
||||
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => {
|
||||
let tokenizerCalled = false;
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: 'まだキャッシュされていない字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeUncached: false,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
tokenizerCalled = true;
|
||||
return { text, tokens: [{ text: 'ま' } as never] };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(tokenizerCalled, false);
|
||||
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
|
||||
const resolvedPayloads: SubtitleData[] = [];
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '起動字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }),
|
||||
onResolvedSubtitle: (resolved) => {
|
||||
resolvedPayloads.push(resolved);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resolvedPayloads, [payload]);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -84,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
calls.push(`request:${name}`);
|
||||
return '国内外から';
|
||||
},
|
||||
}),
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getCurrentSubtitleData: () => null,
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
|
||||
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
|
||||
deferUncachedRefresh: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']);
|
||||
});
|
||||
|
||||
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
|
||||
const calls: string[] = [];
|
||||
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
|
||||
|
||||
@@ -10,24 +10,34 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||
tokenizeUncached?: boolean;
|
||||
onResolvedSubtitle?: (payload: SubtitleData) => void;
|
||||
}): Promise<SubtitleData> {
|
||||
const resolve = (payload: SubtitleData): SubtitleData => {
|
||||
const timedPayload = deps.withCurrentSubtitleTiming(payload);
|
||||
deps.onResolvedSubtitle?.(timedPayload);
|
||||
return timedPayload;
|
||||
};
|
||||
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
return resolve(deps.currentSubtitleData);
|
||||
}
|
||||
|
||||
if (!deps.currentSubText.trim()) {
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
return resolve({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return deps.withCurrentSubtitleTiming(tokenized);
|
||||
if (deps.tokenizeUncached !== false) {
|
||||
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||
if (tokenized) {
|
||||
return resolve(tokenized);
|
||||
}
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
return resolve({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
@@ -41,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
deferUncachedRefresh?: boolean;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
emitSecondarySubtitle?: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
@@ -107,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.deferUncachedRefresh === true) {
|
||||
await primeSecondarySubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.refreshCurrentSubtitle(text);
|
||||
await primeSecondarySubtitle();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
playNextSubtitle: () => calls.push('next'),
|
||||
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||
@@ -34,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.showPlaybackFeedback?.('primary');
|
||||
deps.replayCurrentSubtitle();
|
||||
deps.playNextSubtitle();
|
||||
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||
@@ -48,6 +50,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
'feedback:primary',
|
||||
'replay',
|
||||
'next',
|
||||
'shift:next',
|
||||
|
||||
@@ -3,20 +3,27 @@ import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
|
||||
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
deps: MpvCommandFromIpcRuntimeDeps,
|
||||
) {
|
||||
return (): MpvCommandFromIpcRuntimeDeps => ({
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
||||
});
|
||||
return (): MpvCommandFromIpcRuntimeDeps => {
|
||||
const showPlaybackFeedback = deps.showPlaybackFeedback;
|
||||
return {
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
...(showPlaybackFeedback
|
||||
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
|
||||
: {}),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
osdMessages: false,
|
||||
texthookerEnabled: false,
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveDesiredOverlayInteractive,
|
||||
resolveForegroundSuppressionWithGrace,
|
||||
shouldSuppressPointerInteractionForForegroundWindow,
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './linux-overlay-pointer-interaction';
|
||||
|
||||
@@ -136,6 +137,59 @@ test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldPrimeLinuxOverlayInteractionFromMeasurement primes input from first measured rect', () => {
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => ({
|
||||
...MEASUREMENT,
|
||||
interactiveRects: [{ x: 900, y: 900, width: 320, height: 80 }],
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
shouldSuppressInteraction: () => false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldPrimeLinuxOverlayInteractionFromMeasurement skips hidden or empty startup surfaces', () => {
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => MEASUREMENT,
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getMainWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
getBounds: () => BOUNDS,
|
||||
}),
|
||||
getSubtitleMeasurement: () => ({
|
||||
viewport: MEASUREMENT.viewport,
|
||||
contentRect: null,
|
||||
interactiveRects: [],
|
||||
}),
|
||||
shouldSuspend: () => false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
|
||||
const mapped = mapOverlayMeasurementForPointerInteraction({
|
||||
layer: 'visible',
|
||||
|
||||
@@ -146,6 +146,29 @@ function measuredRectsForInput(measurement: OverlayContentMeasurementLike): Poin
|
||||
: [];
|
||||
}
|
||||
|
||||
function hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean {
|
||||
return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0);
|
||||
}
|
||||
|
||||
export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getMainWindow: () => PointerInteractionWindow | null;
|
||||
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
|
||||
shouldSuspend: () => boolean;
|
||||
shouldSuppressInteraction?: () => boolean;
|
||||
}): boolean {
|
||||
if (!deps.getVisibleOverlayVisible()) return false;
|
||||
if (deps.shouldSuspend()) return false;
|
||||
|
||||
const mainWindow = deps.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deps.shouldSuppressInteraction?.()) return false;
|
||||
return hasMeasuredInputRects(deps.getSubtitleMeasurement());
|
||||
}
|
||||
|
||||
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
|
||||
const left = Math.max(0, Math.floor(rect.x));
|
||||
const top = Math.max(0, Math.floor(rect.y));
|
||||
|
||||
@@ -183,6 +183,34 @@ test('media path change handler signals autoplay readiness from warm media path'
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler schedules character dictionary once per media path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
handler({ path: '/tmp/next-video.mkv' });
|
||||
handler({ path: '' });
|
||||
handler({ path: '/tmp/video.mkv' });
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'dict-sync'),
|
||||
['dict-sync', 'dict-sync', 'dict-sync'],
|
||||
);
|
||||
});
|
||||
|
||||
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
|
||||
@@ -74,9 +74,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
let lastCharacterDictionarySyncMediaPath: string | null = null;
|
||||
|
||||
return ({ path }: { path: string | null }): void => {
|
||||
const normalizedPath = typeof path === 'string' ? path : '';
|
||||
if (!normalizedPath) {
|
||||
const trimmedPath = normalizedPath.trim();
|
||||
if (!trimmedPath) {
|
||||
lastCharacterDictionarySyncMediaPath = null;
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
|
||||
}
|
||||
deps.updateCurrentMediaPath(normalizedPath);
|
||||
@@ -92,9 +96,12 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
deps.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedPath.trim().length > 0) {
|
||||
if (trimmedPath.length > 0) {
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
if (trimmedPath !== lastCharacterDictionarySyncMediaPath) {
|
||||
lastCharacterDictionarySyncMediaPath = trimmedPath;
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
}
|
||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||
}
|
||||
deps.refreshDiscordPresence();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveOverlayReadinessNotificationType,
|
||||
shouldShowDesktop,
|
||||
shouldShowOverlay,
|
||||
shouldShowOsd,
|
||||
} from './notification-routing';
|
||||
|
||||
test('notification routing preserves system notification while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system');
|
||||
});
|
||||
|
||||
test('notification routing preserves both while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both');
|
||||
});
|
||||
|
||||
test('notification routing preserves overlay-only notification while overlay is not ready', () => {
|
||||
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay');
|
||||
});
|
||||
|
||||
test('notification routing predicates classify delivery channels', () => {
|
||||
assert.equal(shouldShowOverlay('both'), true);
|
||||
assert.equal(shouldShowOverlay('system'), false);
|
||||
assert.equal(shouldShowOsd('osd-system'), true);
|
||||
assert.equal(shouldShowOsd('both'), false);
|
||||
assert.equal(shouldShowDesktop('osd-system'), true);
|
||||
assert.equal(shouldShowDesktop('overlay'), false);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NotificationType } from '../../types/notification';
|
||||
|
||||
export function shouldShowOsd(type: NotificationType): boolean {
|
||||
return type === 'osd' || type === 'osd-system';
|
||||
}
|
||||
|
||||
export function shouldShowOverlay(type: NotificationType): boolean {
|
||||
return type === 'overlay' || type === 'both';
|
||||
}
|
||||
|
||||
export function shouldShowDesktop(type: NotificationType): boolean {
|
||||
return type === 'system' || type === 'both' || type === 'osd-system';
|
||||
}
|
||||
|
||||
export function resolveOverlayReadinessNotificationType(
|
||||
type: NotificationType,
|
||||
_overlayReady: boolean,
|
||||
): NotificationType {
|
||||
return type;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createMaybeStartOverlayLoadingOsdHandler,
|
||||
shouldStartOverlayLoadingOsd,
|
||||
} from './overlay-loading-osd-start';
|
||||
|
||||
test('overlay loading OSD starts for visible overlay before content is ready', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD does not start when hidden or already ready', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: false,
|
||||
overlayContentReady: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD media-path trigger ignores empty paths', () => {
|
||||
assert.equal(
|
||||
shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: true,
|
||||
overlayContentReady: false,
|
||||
mediaPath: ' ',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay loading OSD handler starts idempotent status through injected deps', () => {
|
||||
const calls: string[] = [];
|
||||
const maybeStart = createMaybeStartOverlayLoadingOsdHandler({
|
||||
getVisibleOverlayRequested: () => true,
|
||||
isOverlayContentReady: () => false,
|
||||
startOverlayLoadingOsd: () => {
|
||||
calls.push('start');
|
||||
},
|
||||
});
|
||||
|
||||
maybeStart();
|
||||
maybeStart('/tmp/video.mkv');
|
||||
maybeStart(' ');
|
||||
|
||||
assert.deepEqual(calls, ['start', 'start']);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export function shouldStartOverlayLoadingOsd(args: {
|
||||
visibleOverlayRequested: boolean;
|
||||
overlayContentReady: boolean;
|
||||
mediaPath?: string | null;
|
||||
}): boolean {
|
||||
if (!args.visibleOverlayRequested || args.overlayContentReady) {
|
||||
return false;
|
||||
}
|
||||
if (args.mediaPath !== undefined && (args.mediaPath ?? '').trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createMaybeStartOverlayLoadingOsdHandler(deps: {
|
||||
getVisibleOverlayRequested: () => boolean;
|
||||
isOverlayContentReady: () => boolean;
|
||||
startOverlayLoadingOsd: () => void;
|
||||
}) {
|
||||
return (mediaPath?: string | null): void => {
|
||||
if (
|
||||
!shouldStartOverlayLoadingOsd({
|
||||
visibleOverlayRequested: deps.getVisibleOverlayRequested(),
|
||||
overlayContentReady: deps.isOverlayContentReady(),
|
||||
mediaPath,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deps.startOverlayLoadingOsd();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
|
||||
|
||||
test('overlay loading OSD shows spinner ticks and clears when stopped', () => {
|
||||
const messages: string[] = [];
|
||||
const clearedTimers: unknown[] = [];
|
||||
let tick: (() => void) | null = null;
|
||||
const controller = createOverlayLoadingOsdController({
|
||||
showOsd: (message) => {
|
||||
messages.push(message);
|
||||
},
|
||||
clearOsd: () => {
|
||||
messages.push('clear');
|
||||
},
|
||||
setInterval: (callback) => {
|
||||
tick = callback;
|
||||
return 'timer';
|
||||
},
|
||||
clearInterval: (timer) => {
|
||||
clearedTimers.push(timer);
|
||||
},
|
||||
});
|
||||
|
||||
controller.start();
|
||||
controller.start();
|
||||
|
||||
assert.deepEqual(messages, ['Overlay loading |']);
|
||||
if (!tick) {
|
||||
assert.fail('expected spinner tick callback');
|
||||
}
|
||||
const tickCallback: () => void = tick;
|
||||
tickCallback();
|
||||
tickCallback();
|
||||
|
||||
controller.stop();
|
||||
controller.stop();
|
||||
|
||||
assert.deepEqual(messages, ['Overlay loading |', 'Overlay loading /', 'Overlay loading -', 'clear']);
|
||||
assert.deepEqual(clearedTimers, ['timer']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const DEFAULT_OVERLAY_LOADING_OSD_TICK_MS = 180;
|
||||
const OVERLAY_LOADING_OSD_FRAMES = ['|', '/', '-', '\\'] as const;
|
||||
|
||||
export function createOverlayLoadingOsdController(deps: {
|
||||
showOsd: (message: string) => void;
|
||||
clearOsd: () => void;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearInterval?: (timer: unknown) => void;
|
||||
}) {
|
||||
const setIntervalHandler =
|
||||
deps.setInterval ??
|
||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||
const clearIntervalHandler =
|
||||
deps.clearInterval ??
|
||||
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
|
||||
let active = false;
|
||||
let frame = 0;
|
||||
let timer: unknown = null;
|
||||
|
||||
const showNextFrame = (): void => {
|
||||
deps.showOsd(
|
||||
`Overlay loading ${OVERLAY_LOADING_OSD_FRAMES[frame % OVERLAY_LOADING_OSD_FRAMES.length]}`,
|
||||
);
|
||||
frame += 1;
|
||||
};
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (active) {
|
||||
return;
|
||||
}
|
||||
active = true;
|
||||
frame = 0;
|
||||
showNextFrame();
|
||||
timer = setIntervalHandler(showNextFrame, DEFAULT_OVERLAY_LOADING_OSD_TICK_MS);
|
||||
},
|
||||
stop(): void {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (timer !== null) {
|
||||
clearIntervalHandler(timer);
|
||||
timer = null;
|
||||
}
|
||||
deps.clearOsd();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { OverlayNotificationEventPayload } from '../../types/notification';
|
||||
|
||||
export interface OverlayNotificationDeliveryDeps {
|
||||
hasReadyOverlayWindow: () => boolean;
|
||||
send: (payload: OverlayNotificationEventPayload) => void;
|
||||
maxQueuedEvents?: number;
|
||||
flushRetryDelayMs?: number;
|
||||
terminalUpdateDelayMs?: number;
|
||||
scheduleFlushRetry?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearFlushRetry?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
function getPayloadId(payload: OverlayNotificationEventPayload): string | null {
|
||||
return typeof payload.id === 'string' && payload.id.trim().length > 0 ? payload.id : null;
|
||||
}
|
||||
|
||||
function getPayloadHistoryId(payload: OverlayNotificationEventPayload): string | null {
|
||||
if ('dismiss' in payload) {
|
||||
return null;
|
||||
}
|
||||
return typeof payload.historyId === 'string' && payload.historyId.trim().length > 0
|
||||
? payload.historyId
|
||||
: null;
|
||||
}
|
||||
|
||||
function isDismissPayload(
|
||||
payload: OverlayNotificationEventPayload,
|
||||
): payload is Extract<OverlayNotificationEventPayload, { dismiss: true }> {
|
||||
return 'dismiss' in payload && payload.dismiss === true;
|
||||
}
|
||||
|
||||
export function createOverlayNotificationDelivery(deps: OverlayNotificationDeliveryDeps): {
|
||||
send: (payload: OverlayNotificationEventPayload) => void;
|
||||
flush: () => void;
|
||||
getQueuedCount: () => number;
|
||||
} {
|
||||
const maxQueuedEvents = Math.max(1, deps.maxQueuedEvents ?? 32);
|
||||
const flushRetryDelayMs = Math.max(1, deps.flushRetryDelayMs ?? 50);
|
||||
const terminalUpdateDelayMs = Math.max(1, deps.terminalUpdateDelayMs ?? 750);
|
||||
const queuedEvents: OverlayNotificationEventPayload[] = [];
|
||||
let flushRetryHandle: unknown = null;
|
||||
|
||||
const removeQueuedPayloadsById = (id: string): void => {
|
||||
const nextEvents = queuedEvents.filter((queued) => getPayloadId(queued) !== id);
|
||||
queuedEvents.splice(0, queuedEvents.length, ...nextEvents);
|
||||
};
|
||||
|
||||
const clearFlushRetry = (): void => {
|
||||
if (flushRetryHandle === null) {
|
||||
return;
|
||||
}
|
||||
deps.clearFlushRetry?.(flushRetryHandle);
|
||||
flushRetryHandle = null;
|
||||
};
|
||||
|
||||
const scheduleFlushRetry = (delayMs = flushRetryDelayMs): void => {
|
||||
if (!deps.scheduleFlushRetry || flushRetryHandle !== null || queuedEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
flushRetryHandle = deps.scheduleFlushRetry(() => {
|
||||
flushRetryHandle = null;
|
||||
flush();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const queuePayload = (payload: OverlayNotificationEventPayload): void => {
|
||||
const id = getPayloadId(payload);
|
||||
if (isDismissPayload(payload)) {
|
||||
if (id) {
|
||||
removeQueuedPayloadsById(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const payloadPersistent = payload.persistent === true;
|
||||
const payloadHistoryId = getPayloadHistoryId(payload);
|
||||
const existingIndex = queuedEvents.findIndex(
|
||||
(queued) =>
|
||||
getPayloadId(queued) === id &&
|
||||
!isDismissPayload(queued) &&
|
||||
getPayloadHistoryId(queued) === payloadHistoryId &&
|
||||
(queued.persistent === true) === payloadPersistent,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
queuedEvents[existingIndex] = payload;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queuedEvents.push(payload);
|
||||
while (queuedEvents.length > maxQueuedEvents) {
|
||||
queuedEvents.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): void => {
|
||||
if (!deps.hasReadyOverlayWindow()) {
|
||||
scheduleFlushRetry();
|
||||
return;
|
||||
}
|
||||
clearFlushRetry();
|
||||
const readyEvents = queuedEvents.splice(0, queuedEvents.length);
|
||||
const sentPersistentIds = new Set<string>();
|
||||
const deferredTerminalEvents: OverlayNotificationEventPayload[] = [];
|
||||
for (const payload of readyEvents) {
|
||||
const id = getPayloadId(payload);
|
||||
if (
|
||||
id &&
|
||||
!isDismissPayload(payload) &&
|
||||
payload.persistent !== true &&
|
||||
sentPersistentIds.has(id)
|
||||
) {
|
||||
deferredTerminalEvents.push(payload);
|
||||
continue;
|
||||
}
|
||||
deps.send(payload);
|
||||
if (id && !isDismissPayload(payload) && payload.persistent === true) {
|
||||
sentPersistentIds.add(id);
|
||||
}
|
||||
}
|
||||
if (deferredTerminalEvents.length > 0) {
|
||||
if (!deps.scheduleFlushRetry) {
|
||||
for (const payload of deferredTerminalEvents) {
|
||||
deps.send(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
queuedEvents.unshift(...deferredTerminalEvents);
|
||||
scheduleFlushRetry(terminalUpdateDelayMs);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (payload: OverlayNotificationEventPayload): void => {
|
||||
if (isDismissPayload(payload)) {
|
||||
const id = getPayloadId(payload);
|
||||
if (id) {
|
||||
removeQueuedPayloadsById(id);
|
||||
}
|
||||
if (deps.hasReadyOverlayWindow()) {
|
||||
deps.send(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deps.hasReadyOverlayWindow()) {
|
||||
queuePayload(payload);
|
||||
return;
|
||||
}
|
||||
deps.send(payload);
|
||||
};
|
||||
|
||||
return {
|
||||
send,
|
||||
flush,
|
||||
getQueuedCount: () => queuedEvents.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
|
||||
|
||||
test('overlay notification payloads inherit configured overlay position', () => {
|
||||
assert.deepEqual(
|
||||
withConfiguredOverlayNotificationPosition(
|
||||
{ title: 'SubMiner', body: 'Ready' },
|
||||
{ notifications: { overlayPosition: 'top' } },
|
||||
),
|
||||
{ title: 'SubMiner', body: 'Ready', position: 'top' },
|
||||
);
|
||||
});
|
||||
|
||||
test('overlay notification payload position can override configured position', () => {
|
||||
assert.deepEqual(
|
||||
withConfiguredOverlayNotificationPosition(
|
||||
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
|
||||
{ notifications: { overlayPosition: 'top-right' } },
|
||||
),
|
||||
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||
|
||||
export function withConfiguredOverlayNotificationPosition(
|
||||
payload: OverlayNotificationPayload,
|
||||
config: Pick<ResolvedConfig, 'notifications'>,
|
||||
): OverlayNotificationPayload {
|
||||
return {
|
||||
...payload,
|
||||
position: payload.position ?? config.notifications.overlayPosition,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
|
||||
|
||||
type OverlayRuntimeOptionsMainDeps = Parameters<
|
||||
@@ -37,6 +38,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
@@ -72,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
deps.appState.ankiIntegration = integration;
|
||||
},
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '../../types/anki';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { WindowGeometry } from '../../types/runtime';
|
||||
import type { OverlayNotificationPayload } from '../../types/notification';
|
||||
import type { BaseWindowTracker } from '../../window-trackers';
|
||||
|
||||
type OverlayRuntimeOptions = {
|
||||
@@ -31,6 +32,7 @@ type OverlayRuntimeOptions = {
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -64,6 +66,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
} | null;
|
||||
setAnkiIntegration: (integration: unknown | null) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
@@ -91,6 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
|
||||
setAnkiIntegration: deps.setAnkiIntegration,
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
showOverlayNotification: deps.showOverlayNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
|
||||
@@ -36,6 +36,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
isMacOSPlatform: () => true,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
|
||||
dismissOverlayLoadingOsd: () => calls.push('dismiss-overlay-loading-osd'),
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
|
||||
})();
|
||||
|
||||
@@ -60,6 +61,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isWindowsPlatform(), false);
|
||||
deps.showOverlayLoadingOsd('Overlay loading...');
|
||||
deps.dismissOverlayLoadingOsd?.();
|
||||
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
|
||||
assert.equal(trackerNotReadyWarningShown, true);
|
||||
assert.deepEqual(calls, [
|
||||
@@ -71,5 +73,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
'enforce-order',
|
||||
'sync-shortcuts',
|
||||
'overlay-loading-osd',
|
||||
'dismiss-overlay-loading-osd',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
|
||||
hideNonNativeOverlayWhenTargetUnfocused: () =>
|
||||
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user