refactor: make subsync manual-only, default opt-in features off, preserv

- Remove subsync.defaultMode; subsync always opens manual picker
- Default jellyfinRemoteSession warmup and nameMatchEnabled to false
- Stop rewriting config file during legacy migration (resolve in-memory only)
- Fix macOS quit on window-close for --setup launch mode
This commit is contained in:
2026-05-20 21:37:08 -07:00
parent 02a5d95542
commit 525cb7e1fd
35 changed files with 195 additions and 241 deletions
+24 -99
View File
@@ -89,7 +89,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
@@ -222,12 +222,10 @@ test('throws actionable startup parse error for malformed config at construction
);
});
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
test('resolves legacy subtitle appearance options without rewriting config on load', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
@@ -251,63 +249,29 @@ test('migrates legacy subtitle appearance options into css declaration objects o
"font-size": "19px"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
hoverTokenColor?: unknown;
hoverTokenBackgroundColor?: 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.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.deepEqual(parsed.subtitleStyle.css, {
assert.deepEqual(service.getConfig().subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
assert.deepEqual(service.getConfig().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, {
assert.deepEqual(service.getConfig().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', () => {
@@ -2067,12 +2031,10 @@ test('ignores invalid legacy ankiConnect n+1 color value after migration attempt
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"nPlusOne": "#c6a0f6"
@@ -2081,23 +2043,15 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
"color": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('legacy migration failures are logged and rethrown', () => {
@@ -2110,12 +2064,10 @@ test('legacy migration failures are logged and rethrown', () => {
assert.match(catchBlock, /throw error;/);
});
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"highlightEnabled": true,
@@ -2125,20 +2077,12 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
"knownWord": "#a6da95"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: {
knownWords: Record<string, unknown>;
nPlusOne?: Record<string, unknown>;
};
subtitleStyle: { knownWordColor?: string };
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
@@ -2149,28 +2093,14 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
),
);
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
@@ -2183,19 +2113,14 @@ test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () =>
"minSentenceWords": "3"
}
}
}`,
'utf-8',
);
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne: Record<string, unknown> };
};
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
+1 -2
View File
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
primarySubLanguages: ['ja', 'jpn'],
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
mecab: true,
yomitanExtension: true,
subtitleDictionaries: true,
jellyfinRemoteSession: true,
jellyfinRemoteSession: false,
},
updates: {
enabled: true,
+1 -1
View File
@@ -10,7 +10,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: true,
nameMatchEnabled: false,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
-7
View File
@@ -388,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.annotationWebsocket.port,
description: 'Annotated subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'subsync.replace',
kind: 'boolean',
+1 -1
View File
@@ -90,7 +90,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'secondarySub',
},
{
title: 'Auto Subtitle Sync',
title: 'Subtitle Sync',
description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync',
-7
View File
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
if (isObject(src.subsync)) {
const mode = src.subsync.defaultMode;
if (mode === 'auto' || mode === 'manual') {
resolved.subsync.defaultMode = mode;
} else if (mode !== undefined) {
warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.');
}
const alass = asString(src.subsync.alass_path);
if (alass !== undefined) resolved.subsync.alass_path = alass;
const ffsubsync = asString(src.subsync.ffsubsync_path);
+1 -1
View File
@@ -162,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
assert.ok(
warnings.some(
(warning) =>
-1
View File
@@ -149,7 +149,6 @@ export class ConfigService {
if (!migrated) {
return rawConfig;
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
+8 -3
View File
@@ -194,8 +194,8 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
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');
assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -258,7 +258,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.defaultMode',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
@@ -279,6 +279,11 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
}
});
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
+19
View File
@@ -242,3 +242,22 @@ test('startAppLifecycle quits macOS config-only launch when all windows close',
handler();
assert.deepEqual(calls, ['quitApp']);
});
test('startAppLifecycle quits macOS setup-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ setup: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+1 -1
View File
@@ -166,7 +166,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.onWindowAllClosed(() => {
if (
deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings)
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
) {
deps.quitApp();
}
+2 -2
View File
@@ -27,7 +27,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.logging.level = 'debug';
next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.defaultMode = prev.subsync.defaultMode === 'auto' ? 'manual' : 'auto';
next.subsync.replace = !prev.subsync.replace;
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
@@ -58,7 +58,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'logging.level',
'youtube.primarySubLanguages',
'jimaku.maxEntryResults',
'subsync.defaultMode',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
+34 -14
View File
@@ -41,7 +41,6 @@ function makeDeps(
return {
getMpvClient: () => mpvClient,
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg',
@@ -68,7 +67,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
assert.deepEqual(osd, ['Subsync already running']);
});
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
test('triggerSubsyncFromConfig opens manual picker', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let inProgressState: boolean | null = null;
@@ -92,6 +91,31 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
assert.equal(inProgressState, false);
});
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let spinnerRan = false;
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
spinnerRan = true;
return task();
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0;
@@ -161,14 +185,14 @@ test('runSubsyncManual requires a source track for alass', async () => {
});
});
test('triggerSubsyncFromConfig reports path validation failures', async () => {
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
let payloadTrackCount = 0;
await triggerSubsyncFromConfig(
makeDeps({
getResolvedConfig: () => ({
defaultMode: 'auto',
alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg',
@@ -176,16 +200,18 @@ test('triggerSubsyncFromConfig reports path validation failures', async () => {
setSubsyncInProgress: (value) => {
inProgress.push(value);
},
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
}),
);
assert.deepEqual(inProgress, [true, false]);
assert.ok(
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
);
assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
function writeExecutableScript(filePath: string, content: string): void {
@@ -260,7 +286,6 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -326,7 +351,6 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -382,7 +406,6 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -448,7 +471,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -520,7 +542,6 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
@@ -577,7 +598,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
+2 -64
View File
@@ -15,9 +15,6 @@ import {
SubsyncResolvedConfig,
} from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger('main:subsync');
interface FileExtractionResult {
path: string;
@@ -340,57 +337,6 @@ function validateFfsubsyncReference(videoPath: string): void {
}
}
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
try {
secondaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
'alass',
secondaryExtraction.path,
context,
resolved,
client,
);
if (alassResult.ok) {
return alassResult;
}
} catch (error) {
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
}
}
}
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
@@ -448,17 +394,9 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
return;
}
const resolved = deps.getResolvedConfig();
try {
if (resolved.defaultMode === 'manual') {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
@@ -73,6 +73,52 @@ test('update dialog presenter does not focus app or yield before showing non-mac
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter still shows macOS dialog when focus fails', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => {
calls.push('focus');
throw new Error('focus failed');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter still shows macOS dialog when yielding fails', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
throw new Error('yield failed');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('manual update required dialog explains that automatic install is unavailable', async () => {
let shown:
| {
+5 -1
View File
@@ -46,7 +46,11 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => {
await maybeFocusAppForDialog(deps);
try {
await maybeFocusAppForDialog(deps);
} catch {
// Best-effort focus only; never block the dialog itself.
}
return deps.showMessageBox(options);
};
+1 -1
View File
@@ -214,7 +214,7 @@
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
<div class="modal-title">Auto Subtitle Sync</div>
<div class="modal-title">Subtitle Sync</div>
<button id="subsyncClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
+1 -1
View File
@@ -215,7 +215,7 @@ export function createRendererState(): RendererState {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchEnabled: true,
nameMatchEnabled: false,
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
+11 -1
View File
@@ -258,7 +258,7 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
});
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes', () => {
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes when enabled', () => {
const token = createToken({
isKnown: true,
isNPlusOneTarget: true,
@@ -270,6 +270,7 @@ test('computeWordClass applies name-match class ahead of known, n+1, frequency,
assert.equal(
computeWordClass(token, {
nameMatchEnabled: true,
enabled: true,
topX: 100,
mode: 'single',
@@ -280,6 +281,15 @@ test('computeWordClass applies name-match class ahead of known, n+1, frequency,
);
});
test('computeWordClass skips name-match class by default', () => {
const token = createToken({
surface: 'アクア',
}) as MergedToken & { isNameMatch?: boolean };
token.isNameMatch = true;
assert.equal(computeWordClass(token), 'word');
});
test('computeWordClass skips name-match class when disabled', () => {
const token = createToken({
surface: 'アクア',
+2 -2
View File
@@ -93,7 +93,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
};
const DEFAULT_NAME_MATCH_ENABLED = true;
const DEFAULT_NAME_MATCH_ENABLED = false;
function hasPrioritizedNameMatch(
token: MergedToken,
@@ -724,7 +724,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? true;
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? false;
const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6';
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
+1 -3
View File
@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as childProcess from 'child_process';
import * as path from 'path';
import { DEFAULT_CONFIG } from '../config';
import { SubsyncConfig, SubsyncMode } from '../types';
import { SubsyncConfig } from '../types';
export interface MpvTrack {
id?: number;
@@ -17,7 +17,6 @@ export interface MpvTrack {
}
export interface SubsyncResolvedConfig {
defaultMode: SubsyncMode;
alassPath: string;
ffsubsyncPath: string;
ffmpegPath: string;
@@ -89,7 +88,6 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
};
return {
defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode,
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
-3
View File
@@ -67,10 +67,7 @@ export interface MpvConfig {
aniskipButtonKey?: string;
}
export type SubsyncMode = 'auto' | 'manual';
export interface SubsyncConfig {
defaultMode?: SubsyncMode;
alass_path?: string;
ffsubsync_path?: string;
ffmpeg_path?: string;