feat(notifications): add overlay notifications with position config

- Add Catppuccin Macchiato overlay notification stack with 3s transient timeout
- Add `notifications.overlayPosition` config (top-left | top | top-right)
- Route startup tokenization and subtitle annotation status through configured surfaces
- Deduplicate rapid subtitle mode toggle notifications
- Change `both` to mean overlay + system; add `osd-system` as legacy alias for old behavior
- Keep `osd`/`osd-system` as config-file-only legacy values; Settings UI offers overlay/system/both/none
This commit is contained in:
2026-06-04 21:56:51 -07:00
parent 311f1e8ee5
commit 9247248d48
83 changed files with 2296 additions and 240 deletions
+4
View File
@@ -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);
+29 -13
View File
@@ -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', () => {
+13 -2
View File
@@ -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();
@@ -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(),
});