mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-07 03:22:17 -08:00
Overlay 2.0 (#12)
This commit is contained in:
@@ -23,11 +23,32 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.discordPresence.enabled, false);
|
||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
|
||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||
assert.equal(
|
||||
config.subtitleStyle.fontFamily,
|
||||
'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
);
|
||||
assert.equal(config.subtitleStyle.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.lineHeight, 1.35);
|
||||
assert.equal(config.subtitleStyle.letterSpacing, '-0.01em');
|
||||
assert.equal(config.subtitleStyle.wordSpacing, 0);
|
||||
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||
assert.equal(config.subtitleStyle.textShadow, '0 3px 10px rgba(0,0,0,0.69)');
|
||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||
assert.equal(config.subtitleStyle.secondary.fontFamily, 'Inter, Noto Sans, Helvetica Neue, sans-serif');
|
||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||
assert.equal(config.immersionTracking.enabled, true);
|
||||
assert.equal(config.immersionTracking.dbPath, '');
|
||||
assert.equal(config.immersionTracking.batchSize, 25);
|
||||
@@ -98,6 +119,44 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"autoPauseVideoOnHover": true
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.autoPauseVideoOnHover, true);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"autoPauseVideoOnHover": "yes"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.autoPauseVideoOnHover,
|
||||
DEFAULT_CONFIG.subtitleStyle.autoPauseVideoOnHover,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.autoPauseVideoOnHover'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -136,6 +195,44 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"hoverTokenBackgroundColor": "#363a4fd6"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"hoverTokenBackgroundColor": true
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
|
||||
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.enabled and warns for invalid value', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -241,6 +338,72 @@ test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', (
|
||||
);
|
||||
});
|
||||
|
||||
test('parses startup warmup toggles and low-power mode', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"startupWarmups": {
|
||||
"lowPowerMode": true,
|
||||
"mecab": false,
|
||||
"yomitanExtension": true,
|
||||
"subtitleDictionaries": false,
|
||||
"jellyfinRemoteSession": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.startupWarmups.lowPowerMode, true);
|
||||
assert.equal(config.startupWarmups.mecab, false);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, false);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||
});
|
||||
|
||||
test('invalid startup warmup values warn and keep defaults', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"startupWarmups": {
|
||||
"lowPowerMode": "yes",
|
||||
"mecab": 1,
|
||||
"yomitanExtension": null,
|
||||
"subtitleDictionaries": "no",
|
||||
"jellyfinRemoteSession": []
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.startupWarmups.lowPowerMode, DEFAULT_CONFIG.startupWarmups.lowPowerMode);
|
||||
assert.equal(config.startupWarmups.mecab, DEFAULT_CONFIG.startupWarmups.mecab);
|
||||
assert.equal(
|
||||
config.startupWarmups.yomitanExtension,
|
||||
DEFAULT_CONFIG.startupWarmups.yomitanExtension,
|
||||
);
|
||||
assert.equal(
|
||||
config.startupWarmups.subtitleDictionaries,
|
||||
DEFAULT_CONFIG.startupWarmups.subtitleDictionaries,
|
||||
);
|
||||
assert.equal(
|
||||
config.startupWarmups.jellyfinRemoteSession,
|
||||
DEFAULT_CONFIG.startupWarmups.jellyfinRemoteSession,
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.lowPowerMode'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.mecab'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.yomitanExtension'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.subtitleDictionaries'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession'));
|
||||
});
|
||||
|
||||
test('parses discordPresence fields and warns for invalid types', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -597,20 +760,15 @@ test('warns and ignores unknown top-level config keys', () => {
|
||||
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
|
||||
});
|
||||
|
||||
test('parses invisible overlay config and new global shortcuts', () => {
|
||||
test('parses global shortcuts and startup settings', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||
"toggleInvisibleOverlayGlobal": "Alt+Shift+I",
|
||||
"openJimaku": "Ctrl+Alt+J"
|
||||
},
|
||||
"invisibleOverlay": {
|
||||
"startupVisibility": "hidden"
|
||||
},
|
||||
"bind_visible_overlay_to_mpv_sub_visibility": false,
|
||||
"youtubeSubgen": {
|
||||
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||
}
|
||||
@@ -621,10 +779,7 @@ test('parses invisible overlay config and new global shortcuts', () => {
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
||||
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, 'Alt+Shift+I');
|
||||
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
||||
assert.equal(config.invisibleOverlay.startupVisibility, 'hidden');
|
||||
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
|
||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
||||
});
|
||||
|
||||
@@ -632,6 +787,9 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.nPlusOneMatchMode',
|
||||
'anki.kikuFieldGrouping',
|
||||
]);
|
||||
@@ -1090,6 +1248,7 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"logging":/);
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"discordPresence":/);
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
|
||||
@@ -27,9 +27,8 @@ const {
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
subsync,
|
||||
startupWarmups,
|
||||
auto_start_overlay,
|
||||
bind_visible_overlay_to_mpv_sub_visibility,
|
||||
invisibleOverlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
@@ -46,15 +45,14 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
shortcuts,
|
||||
secondarySub,
|
||||
subsync,
|
||||
startupWarmups,
|
||||
subtitleStyle,
|
||||
auto_start_overlay,
|
||||
bind_visible_overlay_to_mpv_sub_visibility,
|
||||
jimaku,
|
||||
anilist,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
youtubeSubgen,
|
||||
invisibleOverlay,
|
||||
immersionTracking,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,8 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'shortcuts'
|
||||
| 'secondarySub'
|
||||
| 'subsync'
|
||||
| 'startupWarmups'
|
||||
| 'auto_start_overlay'
|
||||
| 'bind_visible_overlay_to_mpv_sub_visibility'
|
||||
| 'invisibleOverlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
@@ -28,7 +27,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
|
||||
copySubtitle: 'CommandOrControl+C',
|
||||
copySubtitleMultiple: 'CommandOrControl+Shift+C',
|
||||
updateLastCardFromClipboard: 'CommandOrControl+V',
|
||||
@@ -53,9 +51,12 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ffsubsync_path: '',
|
||||
ffmpeg_path: '',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
bind_visible_overlay_to_mpv_sub_visibility: true,
|
||||
invisibleOverlay: {
|
||||
startupVisibility: 'platform-default',
|
||||
startupWarmups: {
|
||||
lowPowerMode: false,
|
||||
mecab: true,
|
||||
yomitanExtension: true,
|
||||
subtitleDictionaries: true,
|
||||
jellyfinRemoteSession: true,
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
pollingRate: 3000,
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
tags: ['SubMiner'],
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
|
||||
@@ -4,14 +4,22 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
subtitleStyle: {
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
hoverTokenColor: '#c6a0f6',
|
||||
fontFamily:
|
||||
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
|
||||
autoPauseVideoOnHover: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
fontColor: '#cad3f5',
|
||||
fontWeight: 'normal',
|
||||
fontWeight: '600',
|
||||
lineHeight: 1.35,
|
||||
letterSpacing: '-0.01em',
|
||||
wordSpacing: 0,
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 3px 10px rgba(0,0,0,0.69)',
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'rgb(30, 32, 48, 0.88)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
knownWordColor: '#a6da95',
|
||||
jlptColors: {
|
||||
@@ -26,17 +34,24 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
sourcePath: '',
|
||||
topX: 1000,
|
||||
mode: 'single',
|
||||
matchMode: 'headword',
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
fontSize: 24,
|
||||
fontColor: '#ffffff',
|
||||
fontColor: '#cad3f5',
|
||||
lineHeight: 1.35,
|
||||
letterSpacing: '-0.01em',
|
||||
wordSpacing: 0,
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 3px 10px rgba(0,0,0,0.69)',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
fontFamily:
|
||||
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_KEYBINDINGS,
|
||||
RUNTIME_OPTION_REGISTRY,
|
||||
} from '../definitions';
|
||||
import { buildCoreConfigOptionRegistry } from './options-core';
|
||||
@@ -17,6 +18,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'ankiConnect.enabled',
|
||||
'immersionTracking.enabled',
|
||||
@@ -31,6 +33,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'immersionTracking',
|
||||
@@ -57,3 +60,11 @@ test('domain registry builders each contribute entries to composed registry', ()
|
||||
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
|
||||
}
|
||||
});
|
||||
|
||||
test('default keybindings include primary and secondary subtitle track cycling on J keys', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||
});
|
||||
|
||||
@@ -32,18 +32,41 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subsync.defaultMode,
|
||||
description: 'Subsync default mode.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.lowPowerMode',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.startupWarmups.lowPowerMode,
|
||||
description: 'Defer startup warmups except Yomitan extension.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.mecab',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.startupWarmups.mecab,
|
||||
description: 'Warm up MeCab tokenizer at startup.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.yomitanExtension',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.startupWarmups.yomitanExtension,
|
||||
description: 'Warm up Yomitan extension at startup.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.subtitleDictionaries',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.startupWarmups.subtitleDictionaries,
|
||||
description: 'Warm up subtitle dictionaries at startup.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.jellyfinRemoteSession',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
|
||||
description: 'Warm up Jellyfin remote session at startup.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
|
||||
description: 'Timeout for multi-copy/mine modes.',
|
||||
},
|
||||
{
|
||||
path: 'bind_visible_overlay_to_mpv_sub_visibility',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
description:
|
||||
'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
const runtimeOptionById = new Map(runtimeOptionRegistry.map((entry) => [entry.id, entry]));
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'ankiConnect.enabled',
|
||||
@@ -18,6 +20,30 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.pollingRate,
|
||||
description: 'Polling interval in milliseconds.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.proxy.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.proxy.enabled,
|
||||
description: 'Enable local AnkiConnect-compatible proxy for push-based auto-enrichment.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.proxy.host',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.proxy.host,
|
||||
description: 'Bind host for local AnkiConnect proxy.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.proxy.port',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.proxy.port,
|
||||
description: 'Bind port for local AnkiConnect proxy.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.proxy.upstreamUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.proxy.upstreamUrl,
|
||||
description: 'Upstream AnkiConnect URL proxied by local AnkiConnect proxy.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.tags',
|
||||
kind: 'array',
|
||||
@@ -30,7 +56,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
|
||||
description: 'Automatically update newly added cards.',
|
||||
runtime: runtimeOptionRegistry[0],
|
||||
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.matchMode',
|
||||
@@ -81,7 +107,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
enumValues: ['auto', 'manual', 'disabled'],
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
|
||||
description: 'Kiku duplicate-card field grouping mode.',
|
||||
runtime: runtimeOptionRegistry[1],
|
||||
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
|
||||
@@ -21,12 +21,25 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
'Preserve line breaks in visible overlay subtitle rendering. ' +
|
||||
'When false, line breaks are flattened to spaces for a single-line flow.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.autoPauseVideoOnHover,
|
||||
description:
|
||||
'Automatically pause mpv playback while hovering subtitle text, then resume on leave.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.hoverTokenColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
|
||||
description: 'Hex color used for hovered subtitle token highlight in mpv.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.hoverTokenBackgroundColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
|
||||
description: 'CSS color used for hovered subtitle token background highlight in mpv.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -55,6 +68,14 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.matchMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['headword', 'surface'],
|
||||
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.matchMode,
|
||||
description:
|
||||
'headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.singleColor',
|
||||
kind: 'string',
|
||||
|
||||
@@ -19,6 +19,42 @@ export function buildRuntimeOptionRegistry(
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.nPlusOne',
|
||||
path: 'ankiConnect.nPlusOne.highlightEnabled',
|
||||
label: 'N+1 Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: () => ({}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.jlpt',
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
label: 'JLPT Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.subtitleStyle.enableJlpt,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: () => ({}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.frequency',
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
label: 'Frequency Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: () => ({}),
|
||||
},
|
||||
{
|
||||
id: 'anki.nPlusOneMatchMode',
|
||||
path: 'ankiConnect.nPlusOne.matchMode',
|
||||
|
||||
@@ -48,6 +48,8 @@ export const SPECIAL_COMMANDS = {
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
{ key: 'Space', command: ['cycle', 'pause'] },
|
||||
{ key: 'KeyJ', command: ['cycle', 'sid'] },
|
||||
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
|
||||
{ key: 'ArrowRight', command: ['seek', 5] },
|
||||
{ key: 'ArrowLeft', command: ['seek', -5] },
|
||||
{ key: 'ArrowUp', command: ['seek', 60] },
|
||||
|
||||
@@ -8,14 +8,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'auto_start_overlay',
|
||||
},
|
||||
{
|
||||
title: 'Visible Overlay Subtitle Binding',
|
||||
description: [
|
||||
'Control whether visible overlay toggles also toggle MPV subtitle visibility.',
|
||||
'When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.',
|
||||
],
|
||||
key: 'bind_visible_overlay_to_mpv_sub_visibility',
|
||||
},
|
||||
{
|
||||
title: 'Texthooker Server',
|
||||
description: ['Control whether browser opens automatically for texthooker.'],
|
||||
@@ -34,21 +26,21 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
title: 'Startup Warmups',
|
||||
description: [
|
||||
'Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.',
|
||||
'Disable individual warmups to defer load until first real usage.',
|
||||
'lowPowerMode defers all warmups except Yomitan extension.',
|
||||
],
|
||||
key: 'startupWarmups',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
|
||||
key: 'shortcuts',
|
||||
},
|
||||
{
|
||||
title: 'Invisible Overlay',
|
||||
description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
|
||||
notes: [
|
||||
'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.',
|
||||
'This edit-mode shortcut is fixed and is not currently configurable.',
|
||||
],
|
||||
key: 'invisibleOverlay',
|
||||
},
|
||||
{
|
||||
title: 'Keybindings (MPV Commands)',
|
||||
description: [
|
||||
|
||||
@@ -66,3 +66,44 @@ test('warns and falls back for invalid nPlusOne.decks entries', () => {
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks'));
|
||||
});
|
||||
|
||||
test('accepts valid proxy settings', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9999,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.proxy.enabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.proxy.host, '127.0.0.1');
|
||||
assert.equal(context.resolved.ankiConnect.proxy.port, 9999);
|
||||
assert.equal(context.resolved.ankiConnect.proxy.upstreamUrl, 'http://127.0.0.1:8765');
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path.startsWith('ankiConnect.proxy')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('warns and falls back for invalid proxy settings', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
proxy: {
|
||||
enabled: 'yes',
|
||||
host: '',
|
||||
port: -1,
|
||||
upstreamUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.deepEqual(context.resolved.ankiConnect.proxy, DEFAULT_CONFIG.ankiConnect.proxy);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.enabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.host'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
|
||||
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
|
||||
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
|
||||
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
|
||||
const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {};
|
||||
const legacyKeys = new Set([
|
||||
'audioField',
|
||||
@@ -85,6 +86,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
|
||||
: {}),
|
||||
},
|
||||
proxy: {
|
||||
...context.resolved.ankiConnect.proxy,
|
||||
},
|
||||
metadata: {
|
||||
...context.resolved.ankiConnect.metadata,
|
||||
...(isObject(ac.metadata)
|
||||
@@ -153,6 +157,68 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(ac.proxy)) {
|
||||
const proxyEnabled = asBoolean(proxy.enabled);
|
||||
if (proxyEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
|
||||
} else if (proxy.enabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.proxy.enabled',
|
||||
proxy.enabled,
|
||||
context.resolved.ankiConnect.proxy.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const proxyHost = asString(proxy.host);
|
||||
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
|
||||
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
|
||||
} else if (proxy.host !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.proxy.host',
|
||||
proxy.host,
|
||||
context.resolved.ankiConnect.proxy.host,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
|
||||
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
|
||||
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
|
||||
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
|
||||
} else if (proxy.upstreamUrl !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.proxy.upstreamUrl',
|
||||
proxy.upstreamUrl,
|
||||
context.resolved.ankiConnect.proxy.upstreamUrl,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
|
||||
const proxyPort = asNumber(proxy.port);
|
||||
if (
|
||||
proxyPort !== undefined &&
|
||||
Number.isInteger(proxyPort) &&
|
||||
proxyPort >= 1 &&
|
||||
proxyPort <= 65535
|
||||
) {
|
||||
context.resolved.ankiConnect.proxy.port = proxyPort;
|
||||
} else if (proxy.port !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.proxy.port',
|
||||
proxy.port,
|
||||
context.resolved.ankiConnect.proxy.port,
|
||||
'Expected integer between 1 and 65535.',
|
||||
);
|
||||
}
|
||||
} else if (ac.proxy !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.proxy',
|
||||
ac.proxy,
|
||||
context.resolved.ankiConnect.proxy,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(ac.tags)) {
|
||||
const normalizedTags = ac.tags
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
|
||||
@@ -74,10 +74,33 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.startupWarmups)) {
|
||||
const startupWarmupBooleanKeys = [
|
||||
'lowPowerMode',
|
||||
'mecab',
|
||||
'yomitanExtension',
|
||||
'subtitleDictionaries',
|
||||
'jellyfinRemoteSession',
|
||||
] as const;
|
||||
|
||||
for (const key of startupWarmupBooleanKeys) {
|
||||
const value = asBoolean(src.startupWarmups[key]);
|
||||
if (value !== undefined) {
|
||||
resolved.startupWarmups[key] = value as (typeof resolved.startupWarmups)[typeof key];
|
||||
} else if (src.startupWarmups[key] !== undefined) {
|
||||
warn(
|
||||
`startupWarmups.${key}`,
|
||||
src.startupWarmups[key],
|
||||
resolved.startupWarmups[key],
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.shortcuts)) {
|
||||
const shortcutKeys = [
|
||||
'toggleVisibleOverlayGlobal',
|
||||
'toggleInvisibleOverlayGlobal',
|
||||
'copySubtitle',
|
||||
'copySubtitleMultiple',
|
||||
'updateLastCardFromClipboard',
|
||||
@@ -113,24 +136,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.invisibleOverlay)) {
|
||||
const startupVisibility = src.invisibleOverlay.startupVisibility;
|
||||
if (
|
||||
startupVisibility === 'platform-default' ||
|
||||
startupVisibility === 'visible' ||
|
||||
startupVisibility === 'hidden'
|
||||
) {
|
||||
resolved.invisibleOverlay.startupVisibility = startupVisibility;
|
||||
} else if (startupVisibility !== undefined) {
|
||||
warn(
|
||||
'invisibleOverlay.startupVisibility',
|
||||
startupVisibility,
|
||||
resolved.invisibleOverlay.startupVisibility,
|
||||
'Expected platform-default, visible, or hidden.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.secondarySub)) {
|
||||
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
|
||||
resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter(
|
||||
|
||||
@@ -99,10 +99,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (isObject(src.subtitleStyle)) {
|
||||
const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt;
|
||||
const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover =
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
resolved.subtitleStyle = {
|
||||
...resolved.subtitleStyle,
|
||||
...(src.subtitleStyle as ResolvedConfig['subtitleStyle']),
|
||||
frequencyDictionary: {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
...(isObject((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary)
|
||||
? ((src.subtitleStyle as { frequencyDictionary?: unknown })
|
||||
.frequencyDictionary as ResolvedConfig['subtitleStyle']['frequencyDictionary'])
|
||||
: {}),
|
||||
},
|
||||
secondary: {
|
||||
...resolved.subtitleStyle.secondary,
|
||||
...(isObject(src.subtitleStyle.secondary)
|
||||
@@ -141,7 +155,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
|
||||
const autoPauseVideoOnHover = asBoolean(
|
||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
|
||||
);
|
||||
if (autoPauseVideoOnHover !== undefined) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover = autoPauseVideoOnHover;
|
||||
} else if (
|
||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover !==
|
||||
undefined
|
||||
) {
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover = fallbackSubtitleStyleAutoPauseVideoOnHover;
|
||||
warn(
|
||||
'subtitleStyle.autoPauseVideoOnHover',
|
||||
(src.subtitleStyle as { autoPauseVideoOnHover?: unknown }).autoPauseVideoOnHover,
|
||||
resolved.subtitleStyle.autoPauseVideoOnHover,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenColor = asColor(
|
||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
||||
);
|
||||
if (hoverTokenColor !== undefined) {
|
||||
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
|
||||
@@ -154,6 +188,25 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = asString(
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
||||
);
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
} else if (
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
|
||||
undefined
|
||||
) {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor =
|
||||
fallbackSubtitleStyleHoverTokenBackgroundColor;
|
||||
warn(
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor,
|
||||
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
@@ -166,6 +219,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (frequencyEnabled !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled;
|
||||
} else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.enabled = fallbackFrequencyDictionary.enabled;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.enabled',
|
||||
(frequencyDictionary as { enabled?: unknown }).enabled,
|
||||
@@ -178,6 +232,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (sourcePath !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
|
||||
} else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.sourcePath =
|
||||
fallbackFrequencyDictionary.sourcePath;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.sourcePath',
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath,
|
||||
@@ -190,6 +246,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
|
||||
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
|
||||
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.topX = fallbackFrequencyDictionary.topX;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.topX',
|
||||
(frequencyDictionary as { topX?: unknown }).topX,
|
||||
@@ -202,6 +259,7 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (frequencyMode === 'single' || frequencyMode === 'banded') {
|
||||
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
|
||||
} else if (frequencyMode !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.mode = fallbackFrequencyDictionary.mode;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.mode',
|
||||
frequencyDictionary.mode,
|
||||
@@ -210,10 +268,25 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyMatchMode = (frequencyDictionary as { matchMode?: unknown }).matchMode;
|
||||
if (frequencyMatchMode === 'headword' || frequencyMatchMode === 'surface') {
|
||||
resolved.subtitleStyle.frequencyDictionary.matchMode = frequencyMatchMode;
|
||||
} else if (frequencyMatchMode !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.matchMode = fallbackFrequencyDictionary.matchMode;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.matchMode',
|
||||
frequencyMatchMode,
|
||||
resolved.subtitleStyle.frequencyDictionary.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
}
|
||||
|
||||
const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor);
|
||||
if (singleColor !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
|
||||
} else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.singleColor =
|
||||
fallbackFrequencyDictionary.singleColor;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.singleColor',
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor,
|
||||
@@ -228,6 +301,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
if (bandedColors !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
|
||||
} else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.bandedColors =
|
||||
fallbackFrequencyDictionary.bandedColors;
|
||||
warn(
|
||||
'subtitleStyle.frequencyDictionary.bandedColors',
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors,
|
||||
|
||||
@@ -27,3 +27,51 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
autoPauseVideoOnHover: 'invalid' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.autoPauseVideoOnHover' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
frequencyDictionary: {
|
||||
matchMode: 'surface',
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'surface');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
frequencyDictionary: {
|
||||
matchMode: 'reading' as unknown as 'headword' | 'surface',
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.frequencyDictionary.matchMode, 'headword');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.frequencyDictionary.matchMode' &&
|
||||
warning.message === "Expected 'headword' or 'surface'.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,16 +13,4 @@ export function applyTopLevelConfig(context: ResolveContext): void {
|
||||
if (asBoolean(src.auto_start_overlay) !== undefined) {
|
||||
resolved.auto_start_overlay = src.auto_start_overlay as boolean;
|
||||
}
|
||||
|
||||
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) {
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility =
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
|
||||
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
|
||||
warn(
|
||||
'bind_visible_overlay_to_mpv_sub_visibility',
|
||||
src.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
resolved.bind_visible_overlay_to_mpv_sub_visibility,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ function humanizeKey(key: string): string {
|
||||
|
||||
function buildInlineOptionComment(path: string, value: unknown): string {
|
||||
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
|
||||
const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
|
||||
const baseDescription =
|
||||
registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
|
||||
const description =
|
||||
baseDescription && baseDescription.trim().length > 0
|
||||
? normalizeCommentText(baseDescription)
|
||||
|
||||
Reference in New Issue
Block a user