feat(notifications): add overlay notifications with position config (#110)

This commit is contained in:
2026-06-10 22:46:52 -07:00
committed by GitHub
parent c09d009a3e
commit 7be1843c41
177 changed files with 7524 additions and 440 deletions
+68 -4
View File
@@ -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,
+2
View File
@@ -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,
+6 -1
View File
@@ -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: {
+22 -2
View File
@@ -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',
+10
View File
@@ -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.'],
+24 -8
View File
@@ -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')) {
+18 -7
View File
@@ -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.',
);
}
}
}
+10 -1
View File
@@ -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.') ||