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
+108 -5
View File
@@ -1,10 +1,41 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
export interface StartupOsdSequencerCharacterDictionaryEvent {
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
message: string;
}
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => boolean | void }): {
export interface StartupOsdSequencerDeps {
getNotificationType?: () => NotificationType | undefined;
showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification?: (title: string, options: { body?: string }) => void;
}
interface StartupStatusNotificationOptions {
id: string;
title: string;
message: string;
variant: OverlayNotificationPayload['variant'];
persistent: boolean;
desktop?: boolean;
}
function shouldShowOsd(type: NotificationType): boolean {
return type === 'osd' || type === 'osd-system';
}
function shouldShowOverlay(type: NotificationType): boolean {
return type === 'overlay' || type === 'both';
}
function shouldShowDesktop(type: NotificationType): boolean {
return type === 'system' || type === 'both' || type === 'osd-system';
}
export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
reset: () => void;
showTokenizationLoading: (message: string) => void;
markTokenizationReady: () => void;
showAnnotationLoading: (message: string) => void;
markAnnotationLoadingComplete: (message: string) => void;
@@ -12,6 +43,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
} {
let tokenizationReady = false;
let tokenizationWarmupCompleted = false;
let tokenizationLoadingShown = false;
let annotationLoadingMessage: string | null = null;
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
@@ -20,7 +52,66 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
const canShowDictionaryStatus = (): boolean =>
tokenizationReady && annotationLoadingMessage === null;
const showOsd = (message: string): boolean => deps.showOsd(message) !== false;
const getNotificationType = (): NotificationType => deps.getNotificationType?.() ?? 'osd';
const notifyStartupStatus = (options: StartupStatusNotificationOptions): boolean => {
const type = getNotificationType();
if (type === 'none') {
return false;
}
let shown = false;
if (shouldShowOverlay(type)) {
deps.showOverlayNotification?.({
id: options.id,
title: options.title,
body: options.message,
variant: options.variant,
persistent: options.persistent,
});
shown = true;
}
if (shouldShowOsd(type)) {
shown = deps.showOsd(options.message) !== false || shown;
}
if (options.desktop !== false && shouldShowDesktop(type)) {
deps.showDesktopNotification?.('SubMiner', { body: options.message });
shown = true;
}
return shown;
};
const showOsd = (message: string): boolean =>
notifyStartupStatus({
id: 'startup-status',
title: 'SubMiner',
message,
variant: 'info',
persistent: false,
});
const notifyTokenization = (
message: string,
variant: OverlayNotificationPayload['variant'],
persistent: boolean,
): boolean =>
notifyStartupStatus({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
message,
variant,
persistent,
desktop: !persistent,
});
const notifyAnnotation = (
message: string,
variant: OverlayNotificationPayload['variant'],
persistent: boolean,
): boolean =>
notifyStartupStatus({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
message,
variant,
persistent,
desktop: !persistent,
});
const flushBufferedDictionaryStatus = (): boolean => {
if (!canShowDictionaryStatus()) {
@@ -55,17 +146,29 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
return {
reset: () => {
tokenizationReady = tokenizationWarmupCompleted;
tokenizationLoadingShown = false;
annotationLoadingMessage = null;
pendingDictionaryProgress = null;
pendingDictionaryFailure = null;
pendingDictionaryReady = null;
dictionaryProgressShown = false;
},
showTokenizationLoading: (message) => {
if (tokenizationReady) {
return;
}
tokenizationLoadingShown = true;
notifyTokenization(message, 'progress', true);
},
markTokenizationReady: () => {
tokenizationWarmupCompleted = true;
tokenizationReady = true;
if (tokenizationLoadingShown) {
notifyTokenization('Subtitle tokenization ready', 'success', false);
tokenizationLoadingShown = false;
}
if (annotationLoadingMessage !== null) {
showOsd(annotationLoadingMessage);
notifyAnnotation(annotationLoadingMessage, 'progress', true);
return;
}
flushBufferedDictionaryStatus();
@@ -73,7 +176,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
showAnnotationLoading: (message) => {
annotationLoadingMessage = message;
if (tokenizationReady) {
showOsd(message);
notifyAnnotation(message, 'progress', true);
}
},
markAnnotationLoadingComplete: (message) => {
@@ -84,7 +187,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
if (flushBufferedDictionaryStatus()) {
return;
}
showOsd(message);
notifyAnnotation(message, 'success', false);
},
notifyCharacterDictionaryStatus: (event) => {
if (