Files
SubMiner/src/main/runtime/config-hot-reload-handlers.ts
T
sudacode 144373db52 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
2026-06-10 00:06:24 -07:00

212 lines
8.1 KiB
TypeScript

import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveKeybindings } from '../../core/utils/keybindings';
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;
setSessionBindings: (
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
) => void;
refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
applyAniSkipConfig?: () => void;
};
type ConfigHotReloadMessageDeps = {
getNotificationType?: () => NotificationType | undefined;
showMpvOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
};
export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
knownWordColor: config.subtitleStyle.knownWordColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({
keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
statsToggleKey: config.stats.toggleKey,
statsMarkWatchedKey: config.stats.markWatchedKey,
platform:
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
rawConfig: config,
});
return {
keybindings,
sessionBindings,
sessionBindingWarnings,
subtitleStyle: resolveSubtitleStyleForRenderer(config),
subtitleSidebar: config.subtitleSidebar,
primarySubMode: config.subtitleStyle.primaryDefaultMode,
secondarySubMode: config.secondarySub.defaultMode,
};
}
function hasAnyHotReloadField(diff: ConfigHotReloadDiff, prefixes: string[]): boolean {
return diff.hotReloadFields.some((field) =>
prefixes.some((prefix) => field === prefix || field.startsWith(`${prefix}.`)),
);
}
function buildAnkiRuntimeConfigPatch(
diff: ConfigHotReloadDiff,
config: ResolvedConfig,
): Partial<AnkiConnectConfig> | null {
const patch: Partial<AnkiConnectConfig> = {};
if (diff.hotReloadFields.includes('ankiConnect.ai')) {
patch.ai = config.ankiConnect.ai.enabled;
}
if (diff.hotReloadFields.includes('ankiConnect.ai.enabled')) {
patch.ai = config.ankiConnect.ai.enabled;
}
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
}
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
patch.deck = config.ankiConnect.deck;
}
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
patch.knownWords = config.ankiConnect.knownWords;
}
if (hasAnyHotReloadField(diff, ['ankiConnect.nPlusOne'])) {
patch.nPlusOne = config.ankiConnect.nPlusOne;
}
const fieldPatch: NonNullable<AnkiConnectConfig['fields']> = {};
if (diff.hotReloadFields.includes('ankiConnect.fields.word')) {
fieldPatch.word = config.ankiConnect.fields.word;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.audio')) {
fieldPatch.audio = config.ankiConnect.fields.audio;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.image')) {
fieldPatch.image = config.ankiConnect.fields.image;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.sentence')) {
fieldPatch.sentence = config.ankiConnect.fields.sentence;
}
if (diff.hotReloadFields.includes('ankiConnect.fields.miscInfo')) {
fieldPatch.miscInfo = config.ankiConnect.fields.miscInfo;
}
if (Object.keys(fieldPatch).length > 0) {
patch.fields = fieldPatch;
}
if (diff.hotReloadFields.includes('ankiConnect.isLapis.sentenceCardModel')) {
patch.isLapis = { sentenceCardModel: config.ankiConnect.isLapis.sentenceCardModel };
}
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
}
return Object.keys(patch).length > 0 ? patch : null;
}
function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
return hasAnyHotReloadField(diff, [
'ankiConnect.knownWords',
'ankiConnect.nPlusOne',
'ankiConnect.fields.word',
'subtitleStyle.nameMatchEnabled',
'subtitleStyle.nameMatchImagesEnabled',
]);
}
export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config);
deps.setKeybindings(payload.keybindings);
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
if (diff.hotReloadFields.includes('shortcuts')) {
deps.refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
deps.setSecondarySubMode(payload.secondarySubMode);
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config);
if (ankiPatch) {
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
}
if (hasAnnotationRuntimeHotReload(diff)) {
deps.invalidateTokenizationCache?.();
deps.refreshSubtitlePrefetch?.();
deps.refreshCurrentSubtitle?.();
}
if (diff.hotReloadFields.includes('logging.level')) {
deps.setLogLevel?.(config.logging.level);
}
if (diff.hotReloadFields.includes('logging.rotation')) {
deps.setLogRotation?.(config.logging.rotation);
}
if (hasAnyHotReloadField(diff, ['logging.files'])) {
deps.setLogFileToggles?.(config.logging.files);
}
if (hasAnyHotReloadField(diff, ['mpv.aniskipEnabled', 'mpv.aniskipButtonKey'])) {
deps.applyAniSkipConfig?.();
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
}
};
}
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
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 });
}
};
}
export function buildRestartRequiredConfigMessage(fields: string[]): string {
return `Config updated; restart required for: ${fields.join(', ')}`;
}