mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-13 03:13:32 -07:00
feat(notifications): add overlay notifications with position config (#110)
This commit is contained in:
@@ -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.') ||
|
||||
|
||||
Reference in New Issue
Block a user