feat(config): unify mpv plugin options under main config and add CSS/Ani

- Replace subminer.conf plugin config with mpv.* fields in config.jsonc
- Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config
- Add subtitleSidebar.css field; migrate legacy sidebar appearance fields
- Add paintOrder and WebkitTextStroke to subtitle style options
- Update default subtitle/sidebar fontFamily to CJK-first stack
- Fix overlay visible state surviving mpv y-r restart
- Fix live config saves applying subtitle CSS immediately to open overlays
- Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load
- Switch AniSkip button key setting to click-to-learn key capture
This commit is contained in:
2026-05-17 18:01:39 -07:00
parent 0354a0e74b
commit 1ff44e0d69
91 changed files with 2241 additions and 727 deletions
+158 -1
View File
@@ -15,7 +15,7 @@ import { generateConfigTemplate } from './template';
const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
function makeTempDir(): string {
@@ -83,13 +83,19 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.fontKerning, 'normal');
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.paintOrder, '');
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
assert.deepEqual(config.subtitleSidebar.css, {});
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, '');
assert.equal(config.immersionTracking.batchSize, 25);
@@ -113,6 +119,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
assert.equal(config.mpv.backend, 'auto');
assert.equal(config.mpv.autoStartSubMiner, true);
assert.equal(config.mpv.pauseUntilOverlayReady, true);
assert.equal(config.mpv.subminerBinaryPath, '');
assert.equal(config.mpv.aniskipEnabled, true);
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
});
test('parses updates config and warns on invalid values', () => {
@@ -181,6 +194,86 @@ test('throws actionable startup parse error for malformed config at construction
);
});
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
"css": {
"font-size": "44px",
"text-wrap": "balance"
},
"secondary": {
"fontSize": 28,
"fontColor": "#bbbbbb"
}
},
"subtitleSidebar": {
"fontFamily": "M PLUS 1, sans-serif",
"fontSize": 18,
"textColor": "#dddddd",
"timestampColor": "#aaaaaa",
"css": {
"font-size": "19px"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
css?: Record<string, string>;
secondary?: {
fontSize?: unknown;
fontColor?: unknown;
css?: Record<string, string>;
};
};
subtitleSidebar: {
fontFamily?: unknown;
fontSize?: unknown;
textColor?: unknown;
timestampColor?: unknown;
css?: Record<string, string>;
};
};
assert.deepEqual(parsed.subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'text-wrap': 'balance',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
color: '#bbbbbb',
'font-size': '28px',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
assert.deepEqual(parsed.subtitleSidebar.css, {
'font-family': 'M PLUS 1, sans-serif',
color: '#dddddd',
'font-size': '19px',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
});
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
});
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -255,6 +348,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
);
});
test('parses managed mpv plugin runtime settings from config', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "/tmp/custom-subminer.sock",
"backend": "x11",
"autoStartSubMiner": false,
"pauseUntilOverlayReady": false,
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
"aniskipEnabled": false,
"aniskipButtonKey": "F8"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
const config = validService.getConfig();
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
assert.equal(config.mpv.backend, 'x11');
assert.equal(config.mpv.autoStartSubMiner, false);
assert.equal(config.mpv.pauseUntilOverlayReady, false);
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
assert.equal(config.mpv.aniskipEnabled, false);
assert.equal(config.mpv.aniskipButtonKey, 'F8');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"mpv": {
"socketPath": "",
"backend": "weston",
"autoStartSubMiner": "yes",
"pauseUntilOverlayReady": "no",
"subminerBinaryPath": 42,
"aniskipEnabled": "disabled",
"aniskipButtonKey": ""
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const invalidConfig = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
});
test('parses annotationWebsocket settings and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -1,4 +1,5 @@
import { ResolvedConfig } from '../../types/config';
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
@@ -93,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
mpv: {
executablePath: '',
launchMode: 'normal',
socketPath: getDefaultMpvSocketPath(),
backend: 'auto',
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
subminerBinaryPath: '',
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
anilist: {
enabled: false,
+7 -2
View File
@@ -22,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
fontStyle: 'normal',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
@@ -45,7 +47,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
},
secondary: {
css: {},
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 24,
fontColor: '#cad3f5',
lineHeight: 1.35,
@@ -54,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
fontKerning: 'normal',
textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
paintOrder: '',
WebkitTextStroke: '',
backgroundColor: 'transparent',
backdropFilter: 'blur(6px)',
fontWeight: '600',
@@ -67,11 +71,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
css: {},
maxWidth: 420,
opacity: 0.95,
backgroundColor: 'rgba(73, 77, 100, 0.9)',
textColor: '#cad3f5',
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 16,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
@@ -65,6 +65,7 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.jlptColors.N5',
'subtitleStyle.letterSpacing',
'subtitleStyle.lineHeight',
'subtitleStyle.paintOrder',
'subtitleStyle.secondary.backdropFilter',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.fontColor',
@@ -75,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'subtitleStyle.secondary.fontWeight',
'subtitleStyle.secondary.letterSpacing',
'subtitleStyle.secondary.lineHeight',
'subtitleStyle.secondary.paintOrder',
'subtitleStyle.secondary.textRendering',
'subtitleStyle.secondary.textShadow',
'subtitleStyle.secondary.WebkitTextStroke',
'subtitleStyle.secondary.wordSpacing',
'subtitleStyle.textRendering',
'subtitleStyle.textShadow',
'subtitleStyle.WebkitTextStroke',
'subtitleStyle.wordSpacing',
]);
@@ -101,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
'mpv.socketPath',
'mpv.backend',
'mpv.autoStartSubMiner',
'mpv.pauseUntilOverlayReady',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'yomitan.externalProfilePath',
'immersionTracking.enabled',
]) {
+2 -1
View File
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
path: 'auto_start_overlay',
kind: 'boolean',
defaultValue: defaultConfig.auto_start_overlay,
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
description:
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
},
{
path: 'secondarySub.secondarySubLanguages',
+49 -1
View File
@@ -282,7 +282,8 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.nPlusOne.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
description: 'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
description:
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
@@ -448,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.mpv.launchMode,
description: 'Default window state for SubMiner-managed mpv launches.',
},
{
path: 'mpv.socketPath',
kind: 'string',
defaultValue: defaultConfig.mpv.socketPath,
description:
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
},
{
path: 'mpv.backend',
kind: 'enum',
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
defaultValue: defaultConfig.mpv.backend,
description:
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
},
{
path: 'mpv.autoStartSubMiner',
kind: 'boolean',
defaultValue: defaultConfig.mpv.autoStartSubMiner,
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
},
{
path: 'mpv.pauseUntilOverlayReady',
kind: 'boolean',
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
description:
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
},
{
path: 'mpv.subminerBinaryPath',
kind: 'string',
defaultValue: defaultConfig.mpv.subminerBinaryPath,
description:
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
},
{
path: 'mpv.aniskipEnabled',
kind: 'boolean',
defaultValue: defaultConfig.mpv.aniskipEnabled,
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
},
{
path: 'mpv.aniskipButtonKey',
kind: 'string',
defaultValue: defaultConfig.mpv.aniskipButtonKey,
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
@@ -181,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
},
{
path: 'subtitleSidebar.css',
kind: 'object',
defaultValue: defaultConfig.subtitleSidebar.css,
description:
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
},
{
path: 'subtitleSidebar.maxWidth',
kind: 'number',
+6 -3
View File
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Overlay Auto-Start',
title: 'Visible Overlay Auto-Start',
description: [
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
'SubMiner can still auto-start in the background when this is false.',
],
key: 'auto_start_overlay',
},
@@ -166,7 +167,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'MPV Launcher',
description: [
'Optional mpv.exe override for Windows playback entry points.',
'SubMiner-managed mpv launch and bundled plugin options.',
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
],
+91
View File
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
);
}
const socketPath = asString(src.mpv.socketPath);
if (socketPath !== undefined && socketPath.trim().length > 0) {
resolved.mpv.socketPath = socketPath.trim();
} else if (src.mpv.socketPath !== undefined) {
warn(
'mpv.socketPath',
src.mpv.socketPath,
resolved.mpv.socketPath,
'Expected non-empty string.',
);
}
const backend = asString(src.mpv.backend);
if (
backend === 'auto' ||
backend === 'hyprland' ||
backend === 'sway' ||
backend === 'x11' ||
backend === 'macos' ||
backend === 'windows'
) {
resolved.mpv.backend = backend;
} else if (src.mpv.backend !== undefined) {
warn(
'mpv.backend',
src.mpv.backend,
resolved.mpv.backend,
'Expected auto, hyprland, sway, x11, macos, or windows.',
);
}
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
if (autoStartSubMiner !== undefined) {
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
} else if (src.mpv.autoStartSubMiner !== undefined) {
warn(
'mpv.autoStartSubMiner',
src.mpv.autoStartSubMiner,
resolved.mpv.autoStartSubMiner,
'Expected boolean.',
);
}
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
if (pauseUntilOverlayReady !== undefined) {
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
warn(
'mpv.pauseUntilOverlayReady',
src.mpv.pauseUntilOverlayReady,
resolved.mpv.pauseUntilOverlayReady,
'Expected boolean.',
);
}
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
if (subminerBinaryPath !== undefined) {
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
} else if (src.mpv.subminerBinaryPath !== undefined) {
warn(
'mpv.subminerBinaryPath',
src.mpv.subminerBinaryPath,
resolved.mpv.subminerBinaryPath,
'Expected string.',
);
}
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
if (aniskipEnabled !== undefined) {
resolved.mpv.aniskipEnabled = aniskipEnabled;
} else if (src.mpv.aniskipEnabled !== undefined) {
warn(
'mpv.aniskipEnabled',
src.mpv.aniskipEnabled,
resolved.mpv.aniskipEnabled,
'Expected boolean.',
);
}
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
} else if (src.mpv.aniskipButtonKey !== undefined) {
warn(
'mpv.aniskipButtonKey',
src.mpv.aniskipButtonKey,
resolved.mpv.aniskipButtonKey,
'Expected non-empty string.',
);
}
} else if (src.mpv !== undefined) {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
+13
View File
@@ -521,6 +521,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
};
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
if (css !== undefined) {
resolved.subtitleSidebar.css = css;
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
resolved.subtitleSidebar.css = fallback.css;
warn(
'subtitleSidebar.css',
(src.subtitleSidebar as { css?: unknown }).css,
resolved.subtitleSidebar.css,
'Expected an object whose values are CSS declaration strings.',
);
}
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
if (enabled !== undefined) {
resolved.subtitleSidebar.enabled = enabled;
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
);
});
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
const valid = createResolveContext({
subtitleSidebar: {
css: {
'font-size': '18px',
color: '#ffffff',
},
},
});
applySubtitleDomainConfig(valid.context);
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
'font-size': '18px',
color: '#ffffff',
});
const invalid = createResolveContext({
subtitleSidebar: {
css: {
color: 42,
} as never,
},
});
applySubtitleDomainConfig(invalid.context);
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
});
test('subtitleSidebar falls back and warns on invalid values', () => {
const { context, warnings } = createResolveContext({
subtitleSidebar: {
+34 -3
View File
@@ -4,6 +4,7 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
export type ReloadConfigStrictResult =
| {
@@ -49,7 +50,10 @@ export class ConfigService {
if (!loadResult.ok) {
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
}
this.applyResolvedConfig(loadResult.config, loadResult.path);
this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(loadResult.config, loadResult.path),
loadResult.path,
);
}
getConfigPath(): string {
@@ -70,7 +74,10 @@ export class ConfigService {
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = loadRawConfig(this.configPaths);
return this.applyResolvedConfig(config, configPath);
return this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
configPath,
);
}
reloadConfigStrict(): ReloadConfigStrictResult {
@@ -80,7 +87,10 @@ export class ConfigService {
}
const { config, path: configPath } = loadResult;
const resolvedConfig = this.applyResolvedConfig(config, configPath);
const resolvedConfig = this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
configPath,
);
return {
ok: true,
config: resolvedConfig,
@@ -113,4 +123,25 @@ export class ConfigService {
this.warnings = warnings;
return this.getConfig();
}
private migrateLegacySubtitleStyleCssConfig(config: RawConfig, configPath: string): RawConfig {
if (!fs.existsSync(configPath)) {
return config;
}
try {
const content = fs.readFileSync(configPath, 'utf-8');
const migration = applyLegacySubtitleStyleCssMigrationToContent({
content,
rawConfig: config,
});
if (!migration.migrated) {
return config;
}
fs.writeFileSync(configPath, migration.content, 'utf-8');
return migration.rawConfig;
} catch {
return config;
}
}
}
+62 -4
View File
@@ -17,6 +17,17 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
});
test('settings registry groups annotation display fields by config group', () => {
@@ -40,12 +51,19 @@ test('settings registry routes known words sync, n+1, and frequency config to be
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').section, 'Frequency Highlighting');
assert.equal(
field('subtitleStyle.frequencyDictionary.sourcePath').section,
'Frequency Highlighting',
);
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
});
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
});
test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
@@ -54,6 +72,8 @@ test('settings registry exposes specialized controls for config-assisted inputs'
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
assert.equal(field('stats.toggleKey').control, 'key-code');
assert.equal(field('discordPresence.presenceStyle').control, 'select');
});
@@ -80,6 +100,42 @@ test('settings registry exposes css declaration editor for primary and secondary
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
});
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
const sidebarVisible = fields
.filter(
(candidate) =>
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
});
test('settings registry routes playback-related integrations into integrations', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.defaultMode').category, 'integrations');
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -89,10 +145,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
);
const kikuLapis = fields.filter(
(candidate) => candidate.section === 'Kiku/Lapis Features',
assert.ok(
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
);
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
+56 -20
View File
@@ -84,6 +84,7 @@ const JSON_OBJECT_FIELDS = new Set([
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
@@ -92,8 +93,7 @@ const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColo
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
...getSubtitleCssManagedConfigPaths('primary'),
...getSubtitleCssManagedConfigPaths('secondary'),
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
...getSubtitleCssManagedConfigPaths('sidebar'),
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
@@ -102,7 +102,6 @@ const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'appearance',
'behavior',
'mining-anki',
'playback-sources',
'input',
'integrations',
'tracking-app',
@@ -118,12 +117,17 @@ const SECTION_ORDER = new Map<string, number>(
'Playback Pause Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'Visible Overlay Auto-Start',
'YouTube Playback Settings',
'MPV Launcher',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Anki AI',
'AnkiConnect Proxy',
'AnkiConnect',
'AnkiConnect Proxy',
'Jimaku',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
@@ -142,11 +146,23 @@ const PATH_ORDER = new Map<string, number>(
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
'secondarySub.defaultMode',
'secondarySub.secondarySubLanguages',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
'mpv.socketPath',
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'mpv.launchMode',
'mpv.executablePath',
].map((path, index) => [path, index]),
);
@@ -177,10 +193,19 @@ const LABEL_OVERRIDES: Record<string, string> = {
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'subtitleSidebar.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'subtitlePosition.yPercent': 'Subtitle Position',
'mpv.executablePath': 'mpv Executable Path',
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
'mpv.socketPath': 'mpv IPC Socket Path',
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -196,6 +221,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -337,14 +364,17 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (
path.startsWith('mpv.') ||
path.startsWith('youtube.') ||
path.startsWith('youtubeSubgen.') ||
path.startsWith('jimaku.') ||
path.startsWith('subsync.')
) {
return { category: 'playback-sources', section: topSection(path) };
if (path === 'auto_start_overlay') {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
@@ -380,8 +410,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
path.startsWith('logging.')
) {
return { category: 'tracking-app', section: topSection(path) };
}
@@ -399,17 +428,17 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
mpv: 'MPV Launcher',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
auto_start_overlay: 'Visible Overlay Auto-Start',
};
return labels[top] ?? humanizePath(top);
}
@@ -423,6 +452,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
if (path.startsWith('shortcuts.'))
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
if (
path === 'subtitleSidebar.toggleKey' ||
path === 'stats.toggleKey' ||
@@ -543,8 +573,14 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName;
const aSubOrder = a.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder = b.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const aSubOrder =
a.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder =
b.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection;
+121
View File
@@ -0,0 +1,121 @@
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
const STARTUP_MIGRATION_EXCLUDED_PATHS = new Set([
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
]);
export type LegacySubtitleStyleCssMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function hasPath(root: unknown, path: string): boolean {
let current = root;
const segments = path.split('.');
for (const [index, segment] of segments.entries()) {
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
return false;
}
if (index === segments.length - 1) {
return true;
}
current = current[segment];
}
return false;
}
export function buildLegacySubtitleStyleCssMigrationOperations(
rawConfig: RawConfig,
): ConfigSettingsPatchOperation[] {
const operations: ConfigSettingsPatchOperation[] = [];
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(rawConfig, cssPath),
};
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
(legacyPath) =>
!STARTUP_MIGRATION_EXCLUDED_PATHS.has(legacyPath) && hasPath(rawConfig, legacyPath),
);
if (legacyPaths.length === 0) continue;
for (const legacyPath of legacyPaths) {
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
}
operations.push({
op: 'set',
path: cssPath,
value: buildSubtitleCssDeclarationObject(scope, values),
});
for (const legacyPath of legacyPaths) {
operations.push({ op: 'reset', path: legacyPath });
}
}
return operations;
}
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacySubtitleStyleCssMigrationResult {
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
if (operations.length === 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+48 -11
View File
@@ -376,7 +376,6 @@ import {
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
@@ -495,6 +494,7 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import {
createElectronAppUpdater,
@@ -667,7 +667,7 @@ const texthookerService = new Texthooker(() => {
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
config.ankiConnect.nPlusOne.enabled,
);
return {
@@ -1223,6 +1223,17 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
{
socketPath: appState.mpvSocketPath,
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
backend: getResolvedConfig().mpv.backend,
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
},
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1249,12 +1260,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
});
syncInstalledFirstRunPluginBinaryPath({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
binaryPath: process.execPath,
});
const firstRunSetupService = createFirstRunSetupService({
platform: process.platform,
configDir: CONFIG_DIR,
@@ -1814,6 +1819,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
getConfig: () => configService.getConfig(),
getWarnings: () => configService.getWarnings(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(url),
getSettingsWindow: () => appState.configSettingsWindow,
@@ -2625,6 +2631,17 @@ const {
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getPluginRuntimeConfig: () => ({
socketPath: appState.mpvSocketPath,
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
backend: getResolvedConfig().mpv.backend,
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
}),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
removeSocketPath: (socketPath) => {
@@ -4057,6 +4074,17 @@ const {
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
onMpvConnected: () => {
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
@@ -5245,7 +5273,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () =>
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
resolveCurrentSubtitleForRenderer({
currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
}),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
@@ -5582,7 +5614,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
@@ -5648,7 +5680,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
onWindowContentReady: () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
if (appState.currentSubText.trim()) {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}
},
onWindowClosed: (windowKind) => {
if (windowKind === 'visible') {
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
+2 -2
View File
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
label: 'Launch mode',
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'playback-sources',
section: 'mpv launcher',
category: 'behavior',
section: 'MPV Launcher',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
+18 -9
View File
@@ -10,7 +10,10 @@ import type {
} from '../../types/settings';
import type { ReloadConfigStrictResult } from '../../config';
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
import {
createSaveConfigSettingsPatchHandler,
type ConfigSettingsHotReloadDiff,
} from './config-settings-save';
import {
createOpenConfigSettingsWindowHandler,
type ConfigSettingsWindowLike,
@@ -46,6 +49,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
getConfig(): ResolvedConfig;
getWarnings(): ConfigValidationWarning[];
reloadConfigStrict(): ReloadConfigStrictResult;
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
getSettingsWindow(): TWindow | null;
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
@@ -122,6 +126,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
reloadConfigStrict: () => deps.reloadConfigStrict(),
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
onHotReloadApplied: deps.onHotReloadApplied,
});
function ensureConfigFileExists(): string {
@@ -199,20 +204,24 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
(_event, deckName, draftUrl) =>
typeof deckName === 'string'
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(deckName))
: invalidAnkiListResult('Deck name is required.'),
(_event, deckName, draftUrl) => {
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
return normalizedDeckName
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
: invalidAnkiListResult('Deck name is required.');
},
);
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
getAnkiList(draftUrl, (client) => client.modelNames()),
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
(_event, modelName, draftUrl) =>
typeof modelName === 'string'
? getAnkiList(draftUrl, (client) => client.modelFieldNames(modelName))
: invalidAnkiListResult('Note type is required.'),
(_event, modelName, draftUrl) => {
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
return normalizedModelName
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
: invalidAnkiListResult('Note type is required.');
},
);
}
@@ -66,6 +66,76 @@ test('config settings save returns hot-reloadable diff for watcher path', () =>
assert.deepEqual(result.restartRequiredFields, []);
});
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
...DEFAULT_CONFIG,
subtitleStyle: {
...DEFAULT_CONFIG.subtitleStyle,
css: {
'font-size': '50px',
},
secondary: {
...DEFAULT_CONFIG.subtitleStyle.secondary,
css: {
'font-size': '28px',
},
},
},
};
const applied: Array<{
hotReloadFields: string[];
config: ResolvedConfig;
}> = [];
const save = createSaveConfigSettingsPatchHandler({
getConfigPath: () => '/tmp/config.jsonc',
getCurrentConfig: () => previous,
getWarnings: () => [],
getSnapshot: () => snapshot(),
fileExists: () => true,
readText: () => '{}',
writeTextAtomically: () => {},
reloadConfigStrict: (): ReloadConfigStrictResult => ({
ok: true,
config: next,
warnings: [],
path: '/tmp/config.jsonc',
}),
classifyDiff: () => ({
hotReloadFields: ['subtitleStyle'],
restartRequiredFields: [],
}),
getRestartRequiredSections: () => [],
onHotReloadApplied: (diff, config) => {
applied.push({
hotReloadFields: diff.hotReloadFields,
config,
});
},
});
const result = save({
operations: [
{
op: 'set',
path: 'subtitleStyle.css',
value: { 'font-size': '50px' },
},
{
op: 'set',
path: 'subtitleStyle.secondary.css',
value: { 'font-size': '28px' },
},
],
});
assert.equal(result.ok, true);
assert.equal(applied.length, 1);
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
});
test('config settings save returns restart-required sections without applying hot reload', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
+4
View File
@@ -24,6 +24,7 @@ export interface ConfigSettingsSaveDeps {
reloadConfigStrict(): ReloadConfigStrictResult;
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
getRestartRequiredSections(restartRequiredFields: string[]): string[];
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
}
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
@@ -86,6 +87,9 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
}
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied?.(diff, reloadResult.config);
}
return {
ok: true,
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleData } from '../../types';
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
function withTiming(payload: SubtitleData): SubtitleData {
return {
...payload,
startTime: 1,
endTime: 2,
};
}
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '字幕',
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, '字幕');
assert.equal(payload.startTime, 1);
assert.deepEqual(payload.tokens, [{ text: '字' }]);
});
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, '');
assert.equal(payload.tokens, null);
});
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: 'まだキャッシュされていない字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
});
assert.equal(payload.text, 'まだキャッシュされていない字幕');
assert.equal(payload.startTime, 1);
assert.equal(payload.tokens, null);
});
@@ -0,0 +1,23 @@
import type { SubtitleData } from '../../types';
export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubText: string;
currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
}): Promise<SubtitleData> {
if (deps.currentSubtitleData?.text === deps.currentSubText) {
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
}
if (!deps.currentSubText.trim()) {
return deps.withCurrentSubtitleTiming({
text: deps.currentSubText,
tokens: null,
});
}
return deps.withCurrentSubtitleTiming({
text: deps.currentSubText,
tokens: null,
});
}
@@ -10,7 +10,6 @@ import {
removeLegacyMpvPluginCandidates,
resolvePackagedFirstRunPluginAssets,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
@@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () =
});
});
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: true,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
);
});
});
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
homeDir,
xdgConfigHome,
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
});
assert.deepEqual(result, {
updated: false,
configPath: installPaths.pluginConfigPath,
});
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
);
});
});
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+1 -79
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { MpvInstallPaths } from '../../shared/setup-state';
export interface InstalledFirstRunPluginCandidate {
path: string;
@@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult {
failedPaths: Array<{ path: string; message: string }>;
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
function sanitizePluginConfigValue(value: string): string {
return value.replace(/[\r\n]/g, '').trim();
}
function upsertPluginConfigLine(content: string, key: string, value: string): string {
const normalizedValue = sanitizePluginConfigValue(value);
const line = `${key}=${normalizedValue}`;
const pattern = new RegExp(`^${key}=.*$`, 'm');
if (pattern.test(content)) {
return content.replace(pattern, line);
}
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
return `${content}${suffix}${line}\n`;
}
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
const content = fs.readFileSync(configPath, 'utf8');
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
if (updated === content) {
return false;
}
fs.writeFileSync(configPath, updated, 'utf8');
return true;
}
function readInstalledPluginBinaryPath(configPath: string): string | null {
const content = fs.readFileSync(configPath, 'utf8');
const match = content.match(/^binary_path=(.*)$/m);
if (!match) {
return null;
}
const rawValue = match[1] ?? '';
const value = sanitizePluginConfigValue(rawValue);
return value.length > 0 ? value : null;
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
@@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: {
failedPaths,
};
}
export function syncInstalledFirstRunPluginBinaryPath(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
binaryPath: string;
}): { updated: boolean; configPath: string | null } {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
return { updated: false, configPath: null };
}
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
if (configuredBinaryPath) {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
updated,
configPath: installPaths.pluginConfigPath,
};
}
@@ -20,6 +20,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform,
execPath: deps.execPath,
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
defaultMpvLogPath: deps.defaultMpvLogPath,
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
@@ -56,6 +56,51 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
const spawnedArgs: string[][] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: '/opt/SubMiner/SubMiner.AppImage',
getPluginRuntimeConfig: () => ({
socketPath: '/tmp/ignored-config.sock',
binaryPath: '/custom/SubMiner.AppImage',
backend: 'x11',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'F8',
}),
defaultMpvLogPath: '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
spawnedArgs.push(args);
return {
on: () => {},
unref: () => {},
};
},
logWarn: () => {},
logInfo: () => {},
});
launch();
const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts='));
assert.match(scriptOpts ?? '', /subminer-binary_path=\/custom\/SubMiner\.AppImage/);
assert.match(scriptOpts ?? '', /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(scriptOpts ?? '', /subminer-backend=x11/);
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
});
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
let autoLaunchInFlight: Promise<boolean> | null = null;
let launchCalls = 0;
+16 -1
View File
@@ -1,4 +1,8 @@
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import {
buildSubminerPluginRuntimeScriptOptParts,
type SubminerPluginRuntimeScriptOptConfig,
} from '../../shared/subminer-plugin-script-opts';
import type { MpvLaunchMode } from '../../types/config';
type MpvClientLike = {
@@ -40,6 +44,7 @@ export type LaunchMpvForJellyfinDeps = {
getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform;
execPath: string;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
defaultMpvLogPath: string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
@@ -59,7 +64,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
}
}
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
const pluginRuntimeConfig = deps.getPluginRuntimeConfig?.();
const scriptOptParts = pluginRuntimeConfig
? buildSubminerPluginRuntimeScriptOptParts(
{
...pluginRuntimeConfig,
socketPath,
},
deps.execPath,
)
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
const mpvArgs = [
...deps.defaultMpvArgs,
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
@@ -54,6 +54,34 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
});
test('mpv connection handler runs connected hook on connect', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
onConnected: () => calls.push('connected-hook'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => calls.push('schedule'),
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: true });
handler({ connected: false });
assert.deepEqual(calls, [
'presence-refresh',
'sync-overlay-mpv-sub',
'connected-hook',
'presence-refresh',
'report-stop',
]);
});
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
@@ -27,6 +27,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
onConnected?: () => void;
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean;
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
@@ -39,6 +40,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
deps.refreshDiscordPresence();
if (connected) {
deps.syncOverlayMpvSubtitleSuppression();
deps.onConnected?.();
return;
}
deps.reportJellyfinRemoteStopped();
+2 -4
View File
@@ -1,4 +1,5 @@
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
export function createApplyJellyfinMpvDefaultsHandler(deps: {
sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void;
@@ -17,9 +18,6 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
export function createGetDefaultSocketPathHandler(deps: { platform: string }) {
return (): string => {
if (deps.platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket';
}
return '/tmp/subminer-socket';
return getDefaultMpvSocketPath(deps.platform as NodeJS.Platform);
};
}
@@ -95,3 +95,66 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
});
test('main mpv event binder runs mpv-connected callback on connection', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
onMpvConnected: () => calls.push('mpv-connected'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => true,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
setCurrentSubText: () => {},
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: () => {},
broadcastSubtitleAss: () => {},
broadcastSecondarySubtitle: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('connection-change')?.({ connected: true });
assert.ok(calls.includes('mpv-connected'));
});
@@ -25,6 +25,7 @@ type AnilistPostWatchRunOptions = {
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
onMpvConnected?: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void;
scheduleCharacterDictionarySync?: () => void;
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
@@ -83,6 +84,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
onConnected: () => deps.onMpvConnected?.(),
hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
@@ -46,6 +46,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
onMpvConnected?: () => void;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
@@ -93,6 +94,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
hasInitialPlaybackQuitOnDisconnectArg,
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
@@ -191,6 +191,38 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
);
});
test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
const args = buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'normal',
{
socketPath: '\\\\.\\pipe\\ignored-config-socket',
binaryPath: 'C:\\Custom\\SubMiner.exe',
backend: 'windows',
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'F8',
},
);
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
assert.match(scriptOpts ?? '', /subminer-binary_path=C:\\Custom\\SubMiner\.exe/);
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\custom-subminer-socket/);
assert.match(scriptOpts ?? '', /subminer-backend=windows/);
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
});
test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
+17 -4
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
export interface WindowsMpvLaunchDeps {
@@ -102,6 +104,7 @@ export function buildWindowsMpvLaunchArgs(
binaryPath?: string,
pluginEntrypointPath?: string,
launchMode: MpvLaunchMode = 'normal',
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
): string[] {
const launchIdle = targets.length === 0;
const inputIpcServer =
@@ -112,10 +115,18 @@ export function buildWindowsMpvLaunchArgs(
: null;
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
const scriptOptPairs = shouldPassSubminerScriptOpts
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (hasBinaryPath) {
const scriptOptPairs = pluginRuntimeConfig
? buildSubminerPluginRuntimeScriptOptParts(
{
...pluginRuntimeConfig,
socketPath: inputIpcServer,
},
binaryPath ?? '',
)
: shouldPassSubminerScriptOpts
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
if (!pluginRuntimeConfig && hasBinaryPath) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
@@ -149,6 +160,7 @@ export async function launchWindowsMpv(
configuredMpvPath?: string,
launchMode: MpvLaunchMode = 'normal',
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
@@ -192,6 +204,7 @@ export async function launchWindowsMpv(
binaryPath,
runtimePluginEntrypointPath,
launchMode,
pluginRuntimeConfig,
),
);
return { ok: true, mpvPath };
+10
View File
@@ -21,3 +21,13 @@ test('settings preload exposes Anki lookup helpers', () => {
assert.match(source, new RegExp(`${method}:`));
}
});
test('overlay preload queues subtitle updates until renderer listener registration', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
assert.match(
source,
/const onSubtitleSetEvent =\s*createQueuedIpcListenerWithPayload<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
);
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
});
+25 -21
View File
@@ -161,29 +161,39 @@ const onKikuFieldGroupingRequestEvent =
IPC_CHANNELS.event.kikuFieldGroupingRequest,
(payload) => payload as KikuFieldGroupingRequestData,
);
const onSubtitleSetEvent = createQueuedIpcListenerWithPayload<SubtitleData>(
IPC_CHANNELS.event.subtitleSet,
(payload) => payload as SubtitleData,
);
const onSubtitleVisibilityEvent = createQueuedIpcListenerWithPayload<boolean>(
IPC_CHANNELS.event.subtitleVisibility,
(payload) => payload === true,
);
const onSubtitlePositionSetEvent = createQueuedIpcListenerWithPayload<SubtitlePosition | null>(
IPC_CHANNELS.event.subtitlePositionSet,
(payload) => payload as SubtitlePosition | null,
);
const onSecondarySubtitleSetEvent = createQueuedIpcListenerWithPayload<string>(
IPC_CHANNELS.event.secondarySubtitleSet,
(payload) => (typeof payload === 'string' ? payload : ''),
);
const onSecondarySubtitleModeEvent = createQueuedIpcListenerWithPayload<SecondarySubMode>(
IPC_CHANNELS.event.secondarySubtitleMode,
(payload) => payload as SecondarySubMode,
);
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
onSubtitle: (callback: (data: SubtitleData) => void) => {
ipcRenderer.on(IPC_CHANNELS.event.subtitleSet, (_event: IpcRendererEvent, data: SubtitleData) =>
callback(data),
);
onSubtitleSetEvent(callback);
},
onVisibility: (callback: (visible: boolean) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.subtitleVisibility,
(_event: IpcRendererEvent, visible: boolean) => callback(visible),
);
onSubtitleVisibilityEvent(callback);
},
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.subtitlePositionSet,
(_event: IpcRendererEvent, position: SubtitlePosition | null) => {
callback(position);
},
);
onSubtitlePositionSetEvent(callback);
},
getOverlayVisibility: (): Promise<boolean> =>
@@ -290,17 +300,11 @@ const electronAPI: ElectronAPI = {
},
onSecondarySub: (callback: (text: string) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.secondarySubtitleSet,
(_event: IpcRendererEvent, text: string) => callback(text),
);
onSecondarySubtitleSetEvent(callback);
},
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => {
ipcRenderer.on(
IPC_CHANNELS.event.secondarySubtitleMode,
(_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode),
);
onSecondarySubtitleModeEvent(callback);
},
getSecondarySubMode: (): Promise<SecondarySubMode> =>
+36 -1
View File
@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { createKeyboardHandlers } from './keyboard.js';
@@ -108,6 +110,7 @@ function installKeyboardTestGlobals() {
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
let markWatchedKey = 'KeyW';
@@ -216,7 +219,7 @@ function installKeyboardTestGlobals() {
},
electronAPI: {
getKeybindings: async () => [],
getSessionBindings: async () => sessionBindings,
getSessionBindings: () => getSessionBindingsImpl(),
getConfiguredShortcuts: async () => configuredShortcuts,
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
@@ -366,6 +369,9 @@ function installKeyboardTestGlobals() {
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
setMarkActiveVideoWatchedResult: (value: boolean) => {
markActiveVideoWatchedResult = value;
},
@@ -462,6 +468,16 @@ function createKeyboardHandlerHarness() {
};
}
test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'), 'utf8');
const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();');
const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();');
assert.notEqual(keyboardSetupIndex, -1);
assert.notEqual(subtitleRequestIndex, -1);
assert.equal(keyboardSetupIndex < subtitleRequestIndex, true);
});
test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -498,6 +514,25 @@ test('primary subtitle visibility key cycles modes with primary OSD without mpv
}
});
test('mpv input forwarding installs local key handling when session binding IPC stalls', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setGetSessionBindings(() => new Promise<CompiledSessionBinding[]>(() => {}));
const setupResult = await Promise.race([
handlers.setupMpvInputForwarding().then(() => 'resolved'),
wait(25).then(() => 'pending'),
]);
assert.equal(setupResult, 'resolved');
testGlobals.dispatchKeydown({ key: '`', code: 'Backquote' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+38 -1
View File
@@ -44,6 +44,7 @@ export function createKeyboardHandlers(
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
timeout: ReturnType<typeof setTimeout> | null;
} | null = null;
let mpvInputForwardingListenersInstalled = false;
const CHORD_MAP = new Map<
string,
@@ -940,7 +941,7 @@ export function createKeyboardHandlers(
}
}
async function setupMpvInputForwarding(): Promise<void> {
async function loadMpvInputForwardingConfig(): Promise<void> {
const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getConfiguredShortcuts(),
@@ -950,6 +951,42 @@ export function createKeyboardHandlers(
updateSessionBindings(sessionBindings);
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
syncKeyboardTokenSelection();
}
async function setupMpvInputForwarding(): Promise<void> {
installMpvInputForwardingListeners();
syncKeyboardTokenSelection();
let configLoadSettled = false;
let configLoadError: unknown = null;
const configLoad = loadMpvInputForwardingConfig().then(
() => {
configLoadSettled = true;
},
(error) => {
configLoadSettled = true;
configLoadError = error;
console.error('Failed to load overlay keyboard configuration.', error);
},
);
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
if (!configLoadSettled) {
void configLoad;
return;
}
if (configLoadError) {
return;
}
}
function installMpvInputForwardingListeners(): void {
if (mpvInputForwardingListenersInstalled) {
return;
}
mpvInputForwardingListenersInstalled = true;
const subtitleMutationObserver = new MutationObserver(() => {
syncKeyboardTokenSelection();
+13 -1
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { createRendererState } from '../state.js';
import {
createSessionHelpModal,
@@ -30,6 +32,16 @@ test('session help formats bracket keybindings as physical keys', () => {
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
});
test('session help imports browser-safe special command constants', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help.ts'),
'utf8',
);
assert.match(source, /from ['"]\.\.\/\.\.\/config\/definitions\/shared['"]/);
assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/);
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Keybinding } from '../../types';
import type { ShortcutsConfig } from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import type { ModalStateReader, RendererContext } from '../context';
type SessionHelpBindingInfo = {
@@ -141,6 +141,11 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
css: {
'font-size': '22px',
color: '#ffffff',
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
},
},
};
@@ -175,6 +180,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const overlayClassList = createClassList();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const contentStyleValues = new Map<string, string>();
const contentStyle = {
setProperty: (name: string, value: string) => {
contentStyleValues.set(name, value);
},
} as CSSStyleDeclaration & { color?: string };
const ctx = {
dom: {
overlay: { classList: overlayClassList },
@@ -187,6 +198,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
style: contentStyle,
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
@@ -207,6 +219,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
assert.equal(cueList.children.length, 2);
assert.equal(cueList.scrollTop, 0);
assert.deepEqual(cueList.scrollToCalls, []);
assert.equal(contentStyleValues.get('font-size'), '22px');
assert.equal(contentStyle.color, '#ffffff');
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
+19
View File
@@ -55,6 +55,24 @@ function formatCueTimestamp(seconds: number): string {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
function applySidebarCssDeclarations(
target: HTMLElement,
declarations: Record<string, string>,
): void {
const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style;
if (!targetStyle) return;
for (const [property, rawValue] of Object.entries(declarations)) {
const value = rawValue.trim();
if (value.length === 0) continue;
if (property.includes('-')) {
targetStyle.setProperty(property, value);
continue;
}
const styleTarget = targetStyle as unknown as Record<string, string>;
styleTarget[property] = value;
}
}
export function findActiveSubtitleCueIndex(
cues: SubtitleCue[],
current: { text: string; startTime?: number | null } | null,
@@ -266,6 +284,7 @@ export function createSubtitleSidebarModal(
'--subtitle-sidebar-hover-background-color',
snapshot.config.hoverLineBackgroundColor,
);
applySidebarCssDeclarations(ctx.dom.subtitleSidebarContent, snapshot.config.css ?? {});
}
function seekToCue(cue: SubtitleCue): void {
+2 -2
View File
@@ -613,6 +613,8 @@ async function init(): Promise<void> {
});
});
await keyboardHandlers.setupMpvInputForwarding();
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
@@ -698,8 +700,6 @@ async function init(): Promise<void> {
});
});
mouseHandlers.setupDragging();
await keyboardHandlers.setupMpvInputForwarding();
try {
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
} catch (error) {
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
CharacterDictionarySelectionSnapshot,
PrimarySubMode,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleSidebarSnapshotConfig,
SubtitleCue,
SubsyncSourceTrack,
YoutubePickerOpenPayload,
@@ -98,7 +98,7 @@ export type RendererState = {
subtitleSidebarToggleKey: string;
subtitleSidebarPauseVideoOnHover: boolean;
subtitleSidebarAutoScroll: boolean;
subtitleSidebarConfig: Required<SubtitleSidebarConfig> | null;
subtitleSidebarConfig: SubtitleSidebarSnapshotConfig | null;
subtitleSidebarManualScrollUntilMs: number;
subtitleSidebarPausedByHover: boolean;
+5 -5
View File
@@ -1912,10 +1912,10 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
margin-left: auto;
font-family: var(
--subtitle-sidebar-font-family,
'M PLUS 1',
'Noto Sans CJK JP',
'Hiragino Sans',
sans-serif
Hiragino Sans,
M PLUS 1,
Source Han Sans JP,
Noto Sans CJK JP
);
font-size: var(--subtitle-sidebar-font-size, 16px);
background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9));
@@ -2062,7 +2062,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
}
.subtitle-sidebar-timestamp {
font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72);
font-size: 0.72em;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
+17
View File
@@ -58,6 +58,23 @@ test('keyboardEventToConfigKey formats bare key-code fields without modifiers',
);
});
test('keyboardEventToConfigKey formats mpv key bindings from learned input', () => {
assert.equal(
keyboardEventToConfigKey(
{ code: 'Tab', key: 'Tab', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false },
'mpv-key',
),
'TAB',
);
assert.equal(
keyboardEventToConfigKey(
{ code: 'KeyK', key: 'K', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
'mpv-key',
),
'Ctrl+Shift+K',
);
});
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
const rows = createMpvKeybindingRows(defaults, []);
+46 -1
View File
@@ -1,6 +1,6 @@
import type { Keybinding } from '../types/runtime';
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code';
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key';
export interface KeyboardInputLike {
code: string;
@@ -54,6 +54,31 @@ const ELECTRON_KEY_BY_CODE: Record<string, string> = {
Tab: 'Tab',
};
const MPV_KEY_BY_CODE: Record<string, string> = {
Backspace: 'BS',
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
BracketRight: ']',
Comma: ',',
Delete: 'DEL',
End: 'END',
Enter: 'ENTER',
Equal: '=',
Escape: 'ESC',
Home: 'HOME',
Insert: 'INS',
Minus: '-',
PageDown: 'PGDWN',
PageUp: 'PGUP',
Period: '.',
Quote: "'",
Semicolon: ';',
Slash: '/',
Space: 'SPACE',
Tab: 'TAB',
};
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
@@ -79,6 +104,17 @@ function electronKeyToken(input: KeyboardInputLike): string | null {
return ELECTRON_KEY_BY_CODE[input.code] ?? null;
}
function mpvKeyToken(input: KeyboardInputLike): string | null {
if (/^Key[A-Z]$/.test(input.code)) {
return input.key.length === 1 ? input.key : input.code.slice(3).toLowerCase();
}
if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5);
if (/^Numpad[0-9]$/.test(input.code)) return `KP${input.code.slice(6)}`;
if (/^F\d{1,2}$/.test(input.code)) return input.code;
if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '').toUpperCase();
return MPV_KEY_BY_CODE[input.code] ?? null;
}
export function keyboardEventToConfigKey(
input: KeyboardInputLike,
mode: KeyInputMode,
@@ -92,6 +128,15 @@ export function keyboardEventToConfigKey(
}
const parts: string[] = [];
if (mode === 'mpv-key') {
if (input.ctrlKey) parts.push('Ctrl');
if (input.altKey) parts.push('Alt');
if (input.shiftKey) parts.push('Shift');
if (input.metaKey) parts.push('Meta');
const key = mpvKeyToken(input);
return key ? [...parts, key].join('+') : null;
}
if (mode === 'accelerator') {
if (input.ctrlKey || input.metaKey) parts.push('CommandOrControl');
if (input.altKey) parts.push('Alt');
+23 -5
View File
@@ -2,24 +2,42 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as ankiControls from './settings-anki-controls';
test('note field model preference chooses Kiku before configured Lapis default', () => {
test('note field model preference prefers exact Kiku over configured model', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
'Kiku',
);
});
test('note field model preference falls back to Lapis when Kiku is unavailable', () => {
test('note field model preference ignores configured model case-insensitively', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
'Lapis Morph',
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
'Kiku',
);
});
test('note field model preference prefers exact Lapis when Kiku is unavailable', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''),
'Lapis',
);
});
test('note field model preference prefers exact Kiku over exact Lapis', () => {
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Lapis', 'Kiku'], ''), 'Kiku');
});
test('note field model preference does not treat partial Kiku matches as Kiku', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'),
'Lapis Morph',
'',
);
});
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
'',
);
});
+44 -11
View File
@@ -62,9 +62,9 @@ export function selectPreferredNoteFieldModelName(
return exactKiku;
}
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
if (lapis) {
return lapis;
const exactLapis = modelNames.find((name) => name.toLowerCase() === 'lapis');
if (exactLapis) {
return exactLapis;
}
return '';
@@ -111,7 +111,20 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void {
if (state.ankiConnectUrl === nextUrl) {
return;
}
const hasAnkiMetadata =
state.deckNames !== null ||
state.deckNamesLoading ||
state.deckFieldNames.size > 0 ||
state.deckFieldNamesLoading.size > 0 ||
state.modelNames !== null ||
state.modelNamesLoading ||
state.modelFieldNames.size > 0 ||
state.modelFieldNamesLoading.size > 0;
state.ankiConnectUrl = nextUrl;
if (hasAnkiMetadata) {
state.noteFieldModelName = '';
state.noteFieldModelNameManuallySelected = false;
}
state.deckNames = null;
state.deckNamesLoading = false;
state.deckNamesError = null;
@@ -129,9 +142,11 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void {
async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.deckNames || state.deckNamesLoading) return;
const requestUrl = state.ankiConnectUrl;
state.deckNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl);
if (state.ankiConnectUrl !== requestUrl) return;
if (result.ok) {
state.deckNames = uniqueSorted(result.values);
state.deckNamesError = null;
@@ -140,11 +155,14 @@ async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
state.deckNamesError = result.error ?? 'Failed to load Anki decks.';
}
} catch (error) {
if (state.ankiConnectUrl !== requestUrl) return;
state.deckNames = [];
state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.';
} finally {
state.deckNamesLoading = false;
requestRender();
if (state.ankiConnectUrl === requestUrl) {
state.deckNamesLoading = false;
requestRender();
}
}
}
@@ -157,9 +175,11 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom
) {
return;
}
const requestUrl = state.ankiConnectUrl;
state.deckFieldNamesLoading.add(deckName);
try {
const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl);
if (state.ankiConnectUrl !== requestUrl) return;
if (result.ok) {
state.deckFieldNames.set(deckName, uniqueSorted(result.values));
state.deckFieldNamesErrors.delete(deckName);
@@ -171,23 +191,28 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom
);
}
} catch (error) {
if (state.ankiConnectUrl !== requestUrl) return;
state.deckFieldNames.set(deckName, []);
state.deckFieldNamesErrors.set(
deckName,
error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`,
);
} finally {
state.deckFieldNamesLoading.delete(deckName);
requestRender();
if (state.ankiConnectUrl === requestUrl) {
state.deckFieldNamesLoading.delete(deckName);
requestRender();
}
}
}
async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.modelNames || state.modelNamesLoading) return;
const requestUrl = state.ankiConnectUrl;
state.modelNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl);
if (state.ankiConnectUrl !== requestUrl) return;
if (result.ok) {
state.modelNames = uniqueSorted(result.values);
state.modelNamesError = null;
@@ -202,12 +227,15 @@ async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
state.modelNamesError = result.error ?? 'Failed to load Anki note types.';
}
} catch (error) {
if (state.ankiConnectUrl !== requestUrl) return;
state.modelNames = [];
state.modelNamesError =
error instanceof Error ? error.message : 'Failed to load Anki note types.';
} finally {
state.modelNamesLoading = false;
requestRender();
if (state.ankiConnectUrl === requestUrl) {
state.modelNamesLoading = false;
requestRender();
}
}
}
@@ -220,9 +248,11 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr
) {
return;
}
const requestUrl = state.ankiConnectUrl;
state.modelFieldNamesLoading.add(modelName);
try {
const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl);
if (state.ankiConnectUrl !== requestUrl) return;
if (result.ok) {
state.modelFieldNames.set(modelName, uniqueSorted(result.values));
state.modelFieldNamesErrors.delete(modelName);
@@ -234,14 +264,17 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr
);
}
} catch (error) {
if (state.ankiConnectUrl !== requestUrl) return;
state.modelFieldNames.set(modelName, []);
state.modelFieldNamesErrors.set(
modelName,
error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`,
);
} finally {
state.modelFieldNamesLoading.delete(modelName);
requestRender();
if (state.ankiConnectUrl === requestUrl) {
state.modelFieldNamesLoading.delete(modelName);
requestRender();
}
}
}
+4
View File
@@ -153,6 +153,10 @@ export function renderControl(
return renderKeyboardInput(context, field, 'code');
}
if (field.control === 'mpv-key') {
return renderKeyboardInput(context, field, 'mpv-key');
}
if (field.control === 'known-words-decks') {
return renderKnownWordsDecksInput(context, field);
}
+21
View File
@@ -66,6 +66,27 @@ test('filterSettingsFields searches label, section, and config path', () => {
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
});
test('filterSettingsFields normalizes punctuation in query terms', () => {
const nPlusOneFields: ConfigSettingsField[] = [
{
id: 'ankiConnect.nPlusOne.enabled',
label: 'Enable N+1',
description: 'Highlight N+1 cards.',
configPath: 'ankiConnect.nPlusOne.enabled',
category: 'mining-anki',
section: 'N+1',
control: 'boolean',
defaultValue: true,
restartBehavior: 'hot-reload',
},
];
assert.deepEqual(
filterSettingsFields(nPlusOneFields, { query: 'n+1' }).map((field) => field.configPath),
['ankiConnect.nPlusOne.enabled'],
);
});
test('settings draft tracks dirty set and emits save operations', () => {
const draft = createSettingsDraft({
'subtitleStyle.autoPauseVideoOnHover': true,
+2 -2
View File
@@ -38,7 +38,7 @@ export function filterSettingsFields(
filter: SettingsFilter,
): ConfigSettingsField[] {
const query = normalizeQuery(filter.query);
const terms = query.length > 0 ? query.split(/\s+/) : [];
const terms = query.length > 0 ? searchableText([query]).split(/\s+/).filter(Boolean) : [];
return fields.filter((field) => {
if (field.legacyHidden || field.settingsHidden) {
return false;
@@ -46,7 +46,7 @@ export function filterSettingsFields(
if (filter.category && field.category !== filter.category) {
return false;
}
if (!query) {
if (!query || terms.length === 0) {
return true;
}
const haystack = searchableText([
-2
View File
@@ -32,7 +32,6 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
appearance: 'Appearance',
behavior: 'Behavior',
'mining-anki': 'Mining & Anki',
'playback-sources': 'Playback & Sources',
input: 'Input',
integrations: 'Integrations',
'tracking-app': 'Tracking & App',
@@ -43,7 +42,6 @@ const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'appearance',
'behavior',
'mining-anki',
'playback-sources',
'input',
'integrations',
'tracking-app',
+112 -7
View File
@@ -7,12 +7,16 @@ import {
serializeSubtitleCssDeclarations,
} from './subtitle-style-css';
test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => {
test('serializeSubtitleCssDeclarations builds primary CSS from all managed appearance config', () => {
const css = serializeSubtitleCssDeclarations('primary', {
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
'subtitleStyle.fontSize': 35,
'subtitleStyle.fontColor': '#cad3f5',
'subtitleStyle.backgroundColor': 'transparent',
'subtitleStyle.hoverTokenColor': '#f4dbd6',
'subtitleStyle.hoverTokenBackgroundColor': 'rgba(54, 58, 79, 0.84)',
'subtitleStyle.paintOrder': 'stroke fill',
'subtitleStyle.WebkitTextStroke': '1.5px #000',
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
'subtitleStyle.css': {
filter: 'drop-shadow(0 0 8px #000)',
@@ -22,11 +26,21 @@ test('serializeSubtitleCssDeclarations builds primary CSS from config minus defa
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
assert.match(css, /font-size: 35px;/);
assert.match(css, /color: #cad3f5;/);
assert.match(css, /background-color: transparent;/);
assert.match(css, /--subtitle-hover-token-color: #f4dbd6;/);
assert.match(css, /--subtitle-hover-token-background-color: rgba\(54, 58, 79, 0.84\);/);
assert.match(css, /paint-order: stroke fill;/);
assert.match(css, /-webkit-text-stroke: 1.5px #000;/);
assert.doesNotMatch(css, /--subtitle-known-word-color:/);
assert.doesNotMatch(css, /--subtitle-n-plus-one-color:/);
assert.doesNotMatch(css, /--subtitle-name-match-color:/);
assert.doesNotMatch(css, /--subtitle-jlpt-n1-color:/);
assert.doesNotMatch(css, /--subtitle-frequency-single-color:/);
assert.doesNotMatch(css, /--subtitle-frequency-band-1-color:/);
assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/);
assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/);
assert.match(css, /--subtitle-outline: 1px;/);
assert.doesNotMatch(css, /^color:/m);
assert.doesNotMatch(css, /^background-color:/m);
});
test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => {
@@ -42,9 +56,40 @@ test('serializeSubtitleCssDeclarations builds secondary CSS from secondary confi
assert.match(css, /font-family: Noto Sans, sans-serif;/);
assert.match(css, /font-size: 24px;/);
assert.match(css, /color: #cad3f5;/);
assert.match(css, /background-color: transparent;/);
assert.match(css, /text-transform: uppercase;/);
assert.doesNotMatch(css, /^color:/m);
assert.doesNotMatch(css, /^background-color:/m);
});
test('serializeSubtitleCssDeclarations builds sidebar CSS from subtitle sidebar config paths', () => {
const css = serializeSubtitleCssDeclarations('sidebar', {
'subtitleSidebar.fontFamily': 'M PLUS 1, sans-serif',
'subtitleSidebar.fontSize': 16,
'subtitleSidebar.textColor': '#cad3f5',
'subtitleSidebar.backgroundColor': 'rgba(73, 77, 100, 0.9)',
'subtitleSidebar.opacity': 0.95,
'subtitleSidebar.maxWidth': 420,
'subtitleSidebar.timestampColor': '#a5adcb',
'subtitleSidebar.activeLineColor': '#f5bde6',
'subtitleSidebar.activeLineBackgroundColor': 'rgba(138, 173, 244, 0.22)',
'subtitleSidebar.hoverLineBackgroundColor': 'rgba(54, 58, 79, 0.84)',
'subtitleSidebar.css': {
'font-size': '18px',
'text-wrap': 'pretty',
},
});
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
assert.match(css, /font-size: 18px;/);
assert.match(css, /color: #cad3f5;/);
assert.match(css, /background-color: rgba\(73, 77, 100, 0.9\);/);
assert.match(css, /opacity: 0.95;/);
assert.match(css, /--subtitle-sidebar-max-width: 420px;/);
assert.match(css, /--subtitle-sidebar-timestamp-color: #a5adcb;/);
assert.match(css, /--subtitle-sidebar-active-line-color: #f5bde6;/);
assert.match(css, /--subtitle-sidebar-active-background-color: rgba\(138, 173, 244, 0.22\);/);
assert.match(css, /--subtitle-sidebar-hover-background-color: rgba\(54, 58, 79, 0.84\);/);
assert.match(css, /text-wrap: pretty;/);
});
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
@@ -72,17 +117,77 @@ test('parseSubtitleCssDeclarations rejects selectors and malformed declarations'
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
});
test('getSubtitleCssManagedConfigPaths excludes color controls', () => {
test('getSubtitleCssManagedConfigPaths includes all CSS-editor-owned appearance controls', () => {
assert.ok(!getSubtitleCssManagedConfigPaths('primary').includes(''));
assert.ok(!getSubtitleCssManagedConfigPaths('secondary').includes(''));
assert.ok(!getSubtitleCssManagedConfigPaths('sidebar').includes(''));
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
assert.ok(
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
);
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.fontSize'));
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.textColor'));
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.maxWidth'));
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
false,
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.backgroundColor'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('secondary').includes(
'subtitleStyle.secondary.backgroundColor',
),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenColor'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenBackgroundColor'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.paintOrder'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.WebkitTextStroke'),
true,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.knownWordColor'),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nPlusOneColor'),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nameMatchColor'),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.jlptColors.N1'),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes(
'subtitleStyle.frequencyDictionary.singleColor',
),
false,
);
assert.equal(
getSubtitleCssManagedConfigPaths('primary').includes(
'subtitleStyle.frequencyDictionary.bandedColors',
),
false,
);
});
+146 -39
View File
@@ -1,11 +1,10 @@
import type { ConfigSettingsSnapshotValue } from '../types/settings';
export type SubtitleCssScope = 'primary' | 'secondary';
export type SubtitleCssScope = 'primary' | 'secondary' | 'sidebar';
type LegacyCssDeclaration = {
property: string;
primaryPath: string;
secondaryPath: string;
paths: Partial<Record<SubtitleCssScope, string>>;
format?: (value: unknown) => string | undefined;
};
@@ -16,87 +15,187 @@ export type SubtitleCssParseResult =
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
{
property: 'font-family',
primaryPath: 'subtitleStyle.fontFamily',
secondaryPath: 'subtitleStyle.secondary.fontFamily',
paths: {
primary: 'subtitleStyle.fontFamily',
secondary: 'subtitleStyle.secondary.fontFamily',
sidebar: 'subtitleSidebar.fontFamily',
},
},
{
property: 'color',
paths: {
primary: 'subtitleStyle.fontColor',
secondary: 'subtitleStyle.secondary.fontColor',
sidebar: 'subtitleSidebar.textColor',
},
},
{
property: 'background-color',
paths: {
primary: 'subtitleStyle.backgroundColor',
secondary: 'subtitleStyle.secondary.backgroundColor',
sidebar: 'subtitleSidebar.backgroundColor',
},
},
{
property: 'font-size',
primaryPath: 'subtitleStyle.fontSize',
secondaryPath: 'subtitleStyle.secondary.fontSize',
paths: {
primary: 'subtitleStyle.fontSize',
secondary: 'subtitleStyle.secondary.fontSize',
sidebar: 'subtitleSidebar.fontSize',
},
format: formatCssLengthLikeValue,
},
{
property: 'font-weight',
primaryPath: 'subtitleStyle.fontWeight',
secondaryPath: 'subtitleStyle.secondary.fontWeight',
paths: {
primary: 'subtitleStyle.fontWeight',
secondary: 'subtitleStyle.secondary.fontWeight',
},
},
{
property: 'font-style',
primaryPath: 'subtitleStyle.fontStyle',
secondaryPath: 'subtitleStyle.secondary.fontStyle',
paths: {
primary: 'subtitleStyle.fontStyle',
secondary: 'subtitleStyle.secondary.fontStyle',
},
},
{
property: 'line-height',
primaryPath: 'subtitleStyle.lineHeight',
secondaryPath: 'subtitleStyle.secondary.lineHeight',
paths: {
primary: 'subtitleStyle.lineHeight',
secondary: 'subtitleStyle.secondary.lineHeight',
},
},
{
property: 'letter-spacing',
primaryPath: 'subtitleStyle.letterSpacing',
secondaryPath: 'subtitleStyle.secondary.letterSpacing',
paths: {
primary: 'subtitleStyle.letterSpacing',
secondary: 'subtitleStyle.secondary.letterSpacing',
},
},
{
property: 'word-spacing',
primaryPath: 'subtitleStyle.wordSpacing',
secondaryPath: 'subtitleStyle.secondary.wordSpacing',
paths: {
primary: 'subtitleStyle.wordSpacing',
secondary: 'subtitleStyle.secondary.wordSpacing',
},
},
{
property: 'font-kerning',
primaryPath: 'subtitleStyle.fontKerning',
secondaryPath: 'subtitleStyle.secondary.fontKerning',
paths: {
primary: 'subtitleStyle.fontKerning',
secondary: 'subtitleStyle.secondary.fontKerning',
},
},
{
property: 'text-rendering',
primaryPath: 'subtitleStyle.textRendering',
secondaryPath: 'subtitleStyle.secondary.textRendering',
paths: {
primary: 'subtitleStyle.textRendering',
secondary: 'subtitleStyle.secondary.textRendering',
},
},
{
property: 'text-shadow',
primaryPath: 'subtitleStyle.textShadow',
secondaryPath: 'subtitleStyle.secondary.textShadow',
paths: {
primary: 'subtitleStyle.textShadow',
secondary: 'subtitleStyle.secondary.textShadow',
},
},
{
property: 'paint-order',
paths: {
primary: 'subtitleStyle.paintOrder',
secondary: 'subtitleStyle.secondary.paintOrder',
},
},
{
property: '-webkit-text-stroke',
paths: {
primary: 'subtitleStyle.WebkitTextStroke',
secondary: 'subtitleStyle.secondary.WebkitTextStroke',
},
},
{
property: 'backdrop-filter',
primaryPath: 'subtitleStyle.backdropFilter',
secondaryPath: 'subtitleStyle.secondary.backdropFilter',
paths: {
primary: 'subtitleStyle.backdropFilter',
secondary: 'subtitleStyle.secondary.backdropFilter',
},
},
{
property: 'color',
primaryPath: 'subtitleStyle.fontColor',
secondaryPath: 'subtitleStyle.secondary.fontColor',
property: '--subtitle-hover-token-color',
paths: {
primary: 'subtitleStyle.hoverTokenColor',
},
},
{
property: 'background-color',
primaryPath: 'subtitleStyle.backgroundColor',
secondaryPath: 'subtitleStyle.secondary.backgroundColor',
property: '--subtitle-hover-token-background-color',
paths: {
primary: 'subtitleStyle.hoverTokenBackgroundColor',
},
},
{
property: 'opacity',
paths: {
sidebar: 'subtitleSidebar.opacity',
},
},
{
property: '--subtitle-sidebar-max-width',
paths: {
sidebar: 'subtitleSidebar.maxWidth',
},
format: formatCssLengthLikeValue,
},
{
property: '--subtitle-sidebar-timestamp-color',
paths: {
sidebar: 'subtitleSidebar.timestampColor',
},
},
{
property: '--subtitle-sidebar-active-line-color',
paths: {
sidebar: 'subtitleSidebar.activeLineColor',
},
},
{
property: '--subtitle-sidebar-active-background-color',
paths: {
sidebar: 'subtitleSidebar.activeLineBackgroundColor',
},
},
{
property: '--subtitle-sidebar-hover-background-color',
paths: {
sidebar: 'subtitleSidebar.hoverLineBackgroundColor',
},
},
];
const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/;
export function getSubtitleCssPath(scope: SubtitleCssScope): string {
return scope === 'primary' ? 'subtitleStyle.css' : 'subtitleStyle.secondary.css';
if (scope === 'primary') return 'subtitleStyle.css';
if (scope === 'secondary') return 'subtitleStyle.secondary.css';
return 'subtitleSidebar.css';
}
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
return LEGACY_CSS_DECLARATIONS.map((declaration) =>
scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath,
);
return [
...new Set(
LEGACY_CSS_DECLARATIONS.map((declaration) => declaration.paths[scope]).filter(
(path): path is string => typeof path === 'string' && path.length > 0,
),
),
];
}
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
if (path === 'subtitleStyle.css') return 'primary';
if (path === 'subtitleStyle.secondary.css') return 'secondary';
if (path === 'subtitleSidebar.css') return 'sidebar';
return null;
}
@@ -104,10 +203,20 @@ export function serializeSubtitleCssDeclarations(
scope: SubtitleCssScope,
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
): string {
return Object.entries(buildSubtitleCssDeclarationObject(scope, values))
.map(([property, value]) => `${property}: ${value};`)
.join('\n');
}
export function buildSubtitleCssDeclarationObject(
scope: SubtitleCssScope,
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
): Record<string, string> {
const declarations = new Map<string, string>();
for (const declaration of LEGACY_CSS_DECLARATIONS) {
const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath;
const path = declaration.paths[scope];
if (typeof path !== 'string' || path.length === 0) continue;
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
if (formatted !== undefined) {
declarations.set(declaration.property, formatted);
@@ -119,9 +228,7 @@ export function serializeSubtitleCssDeclarations(
declarations.set(normalizeCssPropertyName(property), value);
}
return [...declarations.entries()]
.map(([property, value]) => `${property}: ${value};`)
.join('\n');
return Object.fromEntries(declarations.entries());
}
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
+3
View File
@@ -0,0 +1,3 @@
export function getDefaultMpvSocketPath(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
}
+37
View File
@@ -0,0 +1,37 @@
import type { MpvBackend } from '../types/config';
export interface SubminerPluginRuntimeScriptOptConfig {
socketPath: string;
binaryPath?: string;
backend: MpvBackend;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
texthookerEnabled: boolean;
aniskipEnabled: boolean;
aniskipButtonKey: string;
}
function boolScriptOpt(value: boolean): 'yes' | 'no' {
return value ? 'yes' : 'no';
}
export function buildSubminerPluginRuntimeScriptOptParts(
runtimeConfig: SubminerPluginRuntimeScriptOptConfig,
fallbackAppPath: string,
): string[] {
const binaryPath = runtimeConfig.binaryPath?.trim() || fallbackAppPath;
return [
`subminer-binary_path=${binaryPath}`,
`subminer-socket_path=${runtimeConfig.socketPath}`,
`subminer-backend=${runtimeConfig.backend}`,
`subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`,
`subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`,
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
runtimeConfig.autoStartPauseUntilReady,
)}`,
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
`subminer-aniskip_button_key=${runtimeConfig.aniskipButtonKey}`,
];
}
+17 -1
View File
@@ -30,6 +30,7 @@ import type {
FrequencyDictionaryMatchMode,
FrequencyDictionaryMode,
NPlusOneMatchMode,
ResolvedSubtitleSidebarConfig,
SecondarySubConfig,
SubtitlePosition,
SubtitleSidebarConfig,
@@ -52,10 +53,18 @@ export interface TexthookerConfig {
}
export type MpvLaunchMode = 'normal' | 'maximized' | 'fullscreen';
export type MpvBackend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
export interface MpvConfig {
executablePath?: string;
launchMode?: MpvLaunchMode;
socketPath?: string;
backend?: MpvBackend;
autoStartSubMiner?: boolean;
pauseUntilOverlayReady?: boolean;
subminerBinaryPath?: string;
aniskipEnabled?: boolean;
aniskipButtonKey?: string;
}
export type SubsyncMode = 'auto' | 'manual';
@@ -150,6 +159,13 @@ export interface ResolvedConfig {
mpv: {
executablePath: string;
launchMode: MpvLaunchMode;
socketPath: string;
backend: MpvBackend;
autoStartSubMiner: boolean;
pauseUntilOverlayReady: boolean;
subminerBinaryPath: string;
aniskipEnabled: boolean;
aniskipButtonKey: string;
};
controller: {
enabled: boolean;
@@ -260,7 +276,7 @@ export interface ResolvedConfig {
bandedColors: [string, string, string, string, string];
};
};
subtitleSidebar: Required<SubtitleSidebarConfig>;
subtitleSidebar: ResolvedSubtitleSidebarConfig;
auto_start_overlay: boolean;
jimaku: JimakuConfig & {
apiBaseUrl: string;
+2 -2
View File
@@ -26,10 +26,10 @@ import type {
} from './integrations';
import type {
PrimarySubMode,
ResolvedSubtitleSidebarConfig,
SecondarySubMode,
SubtitleData,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig,
SubtitleStyleConfig,
@@ -345,7 +345,7 @@ export interface ConfigHotReloadPayload {
sessionBindings: CompiledSessionBinding[];
sessionBindingWarnings: SessionBindingWarning[];
subtitleStyle: SubtitleRendererStyleConfig | null;
subtitleSidebar: Required<SubtitleSidebarConfig>;
subtitleSidebar: ResolvedSubtitleSidebarConfig;
primarySubMode: PrimarySubMode;
secondarySubMode: SecondarySubMode;
}
+1 -1
View File
@@ -4,7 +4,6 @@ export type ConfigSettingsCategory =
| 'appearance'
| 'behavior'
| 'mining-anki'
| 'playback-sources'
| 'input'
| 'integrations'
| 'tracking-app'
@@ -22,6 +21,7 @@ export type ConfigSettingsControl =
| 'secret'
| 'keyboard-shortcut'
| 'key-code'
| 'mpv-key'
| 'known-words-decks'
| 'anki-note-type'
| 'anki-field'
+14 -1
View File
@@ -90,6 +90,8 @@ export interface SubtitleStyleConfig {
fontKerning?: string;
textRendering?: string;
textShadow?: string;
paintOrder?: string;
WebkitTextStroke?: string;
backdropFilter?: string;
backgroundColor?: string;
nPlusOneColor?: string;
@@ -123,6 +125,8 @@ export interface SubtitleStyleConfig {
fontKerning?: string;
textRendering?: string;
textShadow?: string;
paintOrder?: string;
WebkitTextStroke?: string;
backdropFilter?: string;
backgroundColor?: string;
};
@@ -167,6 +171,7 @@ export interface SubtitleSidebarConfig {
toggleKey?: string;
pauseVideoOnHover?: boolean;
autoScroll?: boolean;
css?: Record<string, string>;
maxWidth?: number;
opacity?: number;
backgroundColor?: string;
@@ -179,6 +184,14 @@ export interface SubtitleSidebarConfig {
hoverLineBackgroundColor?: string;
}
export type ResolvedSubtitleSidebarConfig = Required<Omit<SubtitleSidebarConfig, 'css'>> & {
css: Record<string, string>;
};
export type SubtitleSidebarSnapshotConfig = Required<Omit<SubtitleSidebarConfig, 'css'>> & {
css?: Record<string, string>;
};
export interface SubtitleData {
text: string;
tokens: MergedToken[] | null;
@@ -194,7 +207,7 @@ export interface SubtitleSidebarSnapshot {
startTime: number | null;
endTime: number | null;
};
config: Required<SubtitleSidebarConfig>;
config: SubtitleSidebarSnapshotConfig;
}
export interface SubtitleHoverTokenPayload {
+14 -21
View File
@@ -1,7 +1,5 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { PathLike } from 'node:fs';
import test from 'node:test';
import {
isCompiledMacOSHelperCurrent,
@@ -34,27 +32,22 @@ test('parseMacOSHelperOutput parses inactive state without geometry', () => {
});
test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-'));
try {
const binaryPath = join(tempDir, 'get-mpv-window-macos');
const sourcePath = join(tempDir, 'get-mpv-window-macos.swift');
writeFileSync(binaryPath, 'binary');
writeFileSync(sourcePath, 'source');
const binaryPath = '/tmp/get-mpv-window-macos';
const sourcePath = '/tmp/get-mpv-window-macos.swift';
const statSync = (binaryMtimeMs: number, sourceMtimeMs: number) => (targetPath: PathLike) =>
({
mtimeMs: String(targetPath) === binaryPath ? binaryMtimeMs : sourceMtimeMs,
}) as never;
const helperFs = {
existsSync: () => true,
statSync: statSync(1_000, 2_000),
};
const older = new Date('2026-01-01T00:00:00Z');
const newer = new Date('2026-01-01T00:00:05Z');
utimesSync(binaryPath, older, older);
utimesSync(sourcePath, newer, newer);
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath, helperFs), false);
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false);
helperFs.statSync = statSync(2_000, 1_000);
utimesSync(binaryPath, newer, newer);
utimesSync(sourcePath, older, older);
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath, helperFs), true);
});
test('MacOSWindowTracker slows polling while focused target is stable', async () => {