feat(config): hot-reload safe config updates and document behavior

This commit is contained in:
2026-02-18 01:04:56 -08:00
parent fd49e73762
commit 4703b995da
18 changed files with 850 additions and 85 deletions

View File

@@ -258,6 +258,55 @@ test('parses jsonc and warns/falls back on invalid value', () => {
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
});
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"logging": {
"level": "warn"
}
}`,
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'warn');
fs.writeFileSync(
configPath,
`{
"logging":`,
);
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSONC.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'warn');
});
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'error');
fs.writeFileSync(configPath, '{"logging":');
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSON.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'error');
});
test('accepts valid logging.level', () => {
const dir = makeTempDir();
fs.writeFileSync(

View File

@@ -715,11 +715,16 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts',
},
{
@@ -737,11 +742,15 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Extra keybindings that are merged with built-in defaults.',
'Set command to null to disable a default keybinding.',
],
notes: [
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
],
key: 'keybindings',
},
{
title: 'Subtitle Appearance',
description: ['Primary and secondary subtitle styling.'],
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle',
},
{
@@ -750,6 +759,7 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Dual subtitle track options.',
'Used by subminer YouTube subtitle generation as secondary language preferences.',
],
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
key: 'secondarySub',
},
{

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
@@ -9,6 +9,19 @@ interface LoadResult {
path: string;
}
export type ReloadConfigStrictResult =
| {
ok: true;
config: ResolvedConfig;
warnings: ConfigValidationWarning[];
path: string;
}
| {
ok: false;
error: string;
path: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -91,6 +104,26 @@ export class ConfigService {
return this.getConfig();
}
reloadConfigStrict(): ReloadConfigStrictResult {
const loadResult = this.loadRawConfigStrict();
if (!loadResult.ok) {
return loadResult;
}
const { config, path: configPath } = loadResult;
this.rawConfig = config;
this.configPathInUse = configPath;
const { resolved, warnings } = this.resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
return {
ok: true,
config: this.getConfig(),
warnings: [...warnings],
path: configPath,
};
}
saveRawConfig(config: RawConfig): void {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
@@ -112,6 +145,20 @@ export class ConfigService {
}
private loadRawConfig(): LoadResult {
const strictResult = this.loadRawConfigStrict();
if (strictResult.ok) {
return strictResult;
}
return { config: {}, path: strictResult.path };
}
private loadRawConfigStrict():
| (LoadResult & { ok: true })
| {
ok: false;
error: string;
path: string;
} {
const configPath = fs.existsSync(this.configFileJsonc)
? this.configFileJsonc
: fs.existsSync(this.configFileJson)
@@ -119,18 +166,29 @@ export class ConfigService {
: this.configFileJsonc;
if (!fs.existsSync(configPath)) {
return { config: {}, path: configPath };
return { ok: true, config: {}, path: configPath };
}
try {
const data = fs.readFileSync(configPath, 'utf-8');
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
const parsed = configPath.endsWith('.jsonc')
? (() => {
const errors: ParseError[] = [];
const result = parseJsonc(data, errors);
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return result;
})()
: JSON.parse(data);
return {
ok: true,
config: isObject(parsed) ? (parsed as Config) : {},
path: configPath,
};
} catch {
return { config: {}, path: configPath };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown parse error';
return { ok: false, error: message, path: configPath };
}
}

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
import {
classifyConfigHotReloadDiff,
createConfigHotReloadRuntime,
type ConfigHotReloadRuntimeDeps,
} from './config-hot-reload';
test('classifyConfigHotReloadDiff separates hot and restart-required fields', () => {
const prev = deepCloneConfig(DEFAULT_CONFIG);
const next = deepCloneConfig(DEFAULT_CONFIG);
next.subtitleStyle.fontSize = prev.subtitleStyle.fontSize + 2;
next.websocket.port = prev.websocket.port + 1;
const diff = classifyConfigHotReloadDiff(prev, next);
assert.deepEqual(diff.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(diff.restartRequiredFields, ['websocket']);
});
test('config hot reload runtime debounces rapid watch events', () => {
let watchedChangeCallback: (() => void) | null = null;
const pendingTimers = new Map<number, () => void>();
let nextTimerId = 1;
let reloadCalls = 0;
const deps: ConfigHotReloadRuntimeDeps = {
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => {
reloadCalls += 1;
return {
ok: true,
config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [],
path: '/tmp/config.jsonc',
};
},
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
const id = nextTimerId;
nextTimerId += 1;
pendingTimers.set(id, callback);
return id as unknown as NodeJS.Timeout;
},
clearTimeout: (timeout) => {
pendingTimers.delete(timeout as unknown as number);
},
debounceMs: 25,
onHotReloadApplied: () => {},
onRestartRequired: () => {},
onInvalidConfig: () => {},
};
const runtime = createConfigHotReloadRuntime(deps);
runtime.start();
assert.equal(reloadCalls, 1);
if (!watchedChangeCallback) {
throw new Error('Expected watch callback to be registered.');
}
const trigger = watchedChangeCallback as () => void;
trigger();
trigger();
trigger();
assert.equal(pendingTimers.size, 1);
for (const callback of pendingTimers.values()) {
callback();
}
assert.equal(reloadCalls, 2);
});
test('config hot reload runtime reports invalid config and skips apply', () => {
const invalidMessages: string[] = [];
let watchedChangeCallback: (() => void) | null = null;
const runtime = createConfigHotReloadRuntime({
getCurrentConfig: () => deepCloneConfig(DEFAULT_CONFIG),
reloadConfigStrict: () => ({
ok: false,
error: 'Invalid JSON',
path: '/tmp/config.jsonc',
}),
watchConfigPath: (_path, onChange) => {
watchedChangeCallback = onChange;
return { close: () => {} };
},
setTimeout: (callback) => {
callback();
return 1 as unknown as NodeJS.Timeout;
},
clearTimeout: () => {},
debounceMs: 0,
onHotReloadApplied: () => {
throw new Error('Hot reload should not apply for invalid config.');
},
onRestartRequired: () => {
throw new Error('Restart warning should not trigger for invalid config.');
},
onInvalidConfig: (message) => {
invalidMessages.push(message);
},
});
runtime.start();
assert.equal(watchedChangeCallback, null);
assert.equal(invalidMessages.length, 1);
});

View File

@@ -0,0 +1,159 @@
import { type ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types';
export interface ConfigHotReloadDiff {
hotReloadFields: string[];
restartRequiredFields: string[];
}
export interface ConfigHotReloadRuntimeDeps {
getCurrentConfig: () => ResolvedConfig;
reloadConfigStrict: () => ReloadConfigStrictResult;
watchConfigPath: (configPath: string, onChange: () => void) => { close: () => void };
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
clearTimeout: (timeout: NodeJS.Timeout) => void;
debounceMs?: number;
onHotReloadApplied: (diff: ConfigHotReloadDiff, config: ResolvedConfig) => void;
onRestartRequired: (fields: string[]) => void;
onInvalidConfig: (message: string) => void;
}
export interface ConfigHotReloadRuntime {
start: () => void;
stop: () => void;
}
function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) {
hotReloadFields.push('subtitleStyle');
}
if (!isEqual(prev.keybindings, next.keybindings)) {
hotReloadFields.push('keybindings');
}
if (!isEqual(prev.shortcuts, next.shortcuts)) {
hotReloadFields.push('shortcuts');
}
if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) {
hotReloadFields.push('secondarySub.defaultMode');
}
if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) {
hotReloadFields.push('ankiConnect.ai');
}
const keys = new Set([
...(Object.keys(prev) as Array<keyof ResolvedConfig>),
...(Object.keys(next) as Array<keyof ResolvedConfig>),
]);
for (const key of keys) {
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts') {
continue;
}
if (key === 'secondarySub') {
const normalizedPrev = {
...prev.secondarySub,
defaultMode: next.secondarySub.defaultMode,
};
if (!isEqual(normalizedPrev, next.secondarySub)) {
restartRequiredFields.push('secondarySub');
}
continue;
}
if (key === 'ankiConnect') {
const normalizedPrev = {
...prev.ankiConnect,
ai: next.ankiConnect.ai,
};
if (!isEqual(normalizedPrev, next.ankiConnect)) {
restartRequiredFields.push('ankiConnect');
}
continue;
}
if (!isEqual(prev[key], next[key])) {
restartRequiredFields.push(String(key));
}
}
return { hotReloadFields, restartRequiredFields };
}
export function createConfigHotReloadRuntime(
deps: ConfigHotReloadRuntimeDeps,
): ConfigHotReloadRuntime {
let watcher: { close: () => void } | null = null;
let timer: NodeJS.Timeout | null = null;
let watchedPath: string | null = null;
const debounceMs = deps.debounceMs ?? 250;
const reloadWithDiff = () => {
const prev = deps.getCurrentConfig();
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config reload failed: ${result.error}`);
return;
}
if (watchedPath !== result.path) {
watchPath(result.path);
}
const diff = classifyDiff(prev, result.config);
if (diff.hotReloadFields.length > 0) {
deps.onHotReloadApplied(diff, result.config);
}
if (diff.restartRequiredFields.length > 0) {
deps.onRestartRequired(diff.restartRequiredFields);
}
};
const scheduleReload = () => {
if (timer) {
deps.clearTimeout(timer);
}
timer = deps.setTimeout(() => {
timer = null;
reloadWithDiff();
}, debounceMs);
};
const watchPath = (configPath: string) => {
watcher?.close();
watcher = deps.watchConfigPath(configPath, scheduleReload);
watchedPath = configPath;
};
return {
start: () => {
if (watcher) {
return;
}
const result = deps.reloadConfigStrict();
if (!result.ok) {
deps.onInvalidConfig(`Config watcher startup failed: ${result.error}`);
return;
}
watchPath(result.path);
},
stop: () => {
if (timer) {
deps.clearTimeout(timer);
timer = null;
}
watcher?.close();
watcher = null;
watchedPath = null;
},
};
}
export { classifyDiff as classifyConfigHotReloadDiff };

View File

@@ -108,3 +108,4 @@ export {
createOverlayManager,
setOverlayDebugVisualizationEnabledRuntime,
} from './overlay-manager';
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';

View File

@@ -23,6 +23,9 @@ import {
shell,
protocol,
Extension,
Menu,
Tray,
nativeImage,
} from 'electron';
protocol.registerSchemesAsPrivileged([
@@ -57,6 +60,7 @@ import type {
RuntimeOptionState,
MpvSubtitleRenderMetrics,
ResolvedConfig,
ConfigHotReloadPayload,
} from './types';
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { AnkiIntegration } from './anki-integration';
@@ -119,6 +123,7 @@ import {
runStartupBootstrapRuntime,
saveSubtitlePosition as saveSubtitlePositionCore,
authenticateWithPasswordRuntime,
createConfigHotReloadRuntime,
resolveJellyfinPlaybackPlanRuntime,
jellyfinTicksToSecondsRuntime,
sendMpvCommandRuntime,
@@ -194,6 +199,7 @@ const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json';
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
const TRAY_TOOLTIP = 'SubMiner';
let anilistCurrentMediaKey: string | null = null;
let anilistCurrentMediaDurationSec: number | null = null;
@@ -357,6 +363,7 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
let appTray: Tray | null = null;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
@@ -396,6 +403,64 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
},
});
const configHotReloadRuntime = createConfigHotReloadRuntime({
getCurrentConfig: () => getResolvedConfig(),
reloadConfigStrict: () => configService.reloadConfigStrict(),
watchConfigPath: (configPath, onChange) => {
const watchTarget = fs.existsSync(configPath) ? configPath : path.dirname(configPath);
const watcher = fs.watch(watchTarget, (_eventType, filename) => {
if (watchTarget === configPath) {
onChange();
return;
}
const normalized =
typeof filename === 'string' ? filename : filename ? String(filename) : undefined;
if (!normalized || normalized === 'config.json' || normalized === 'config.jsonc') {
onChange();
}
});
return {
close: () => {
watcher.close();
},
};
},
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timeout) => clearTimeout(timeout),
debounceMs: 250,
onHotReloadApplied: (diff, config) => {
const payload = buildConfigHotReloadPayload(config);
appState.keybindings = payload.keybindings;
if (diff.hotReloadFields.includes('shortcuts')) {
refreshGlobalAndOverlayShortcuts();
}
if (diff.hotReloadFields.includes('secondarySub.defaultMode')) {
appState.secondarySubMode = payload.secondarySubMode;
broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
}
if (diff.hotReloadFields.includes('ankiConnect.ai') && appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch({ ai: config.ankiConnect.ai });
}
if (diff.hotReloadFields.length > 0) {
broadcastToOverlayWindows('config:hot-reload', payload);
}
},
onRestartRequired: (fields) => {
const message = `Config updated; restart required for: ${fields.join(', ')}`;
showMpvOsd(message);
showDesktopNotification('SubMiner', { body: message });
},
onInvalidConfig: (message) => {
showMpvOsd(message);
showDesktopNotification('SubMiner', { body: message });
},
});
const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({
isJlptEnabled: () => getResolvedConfig().subtitleStyle.enableJlpt,
getSearchPaths: () =>
@@ -590,6 +655,28 @@ function openRuntimeOptionsPalette(): void {
function getResolvedConfig() {
return configService.getConfig();
}
function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
if (!config.subtitleStyle) {
return null;
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
};
}
function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
return {
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
subtitleStyle: resolveSubtitleStyleForRenderer(config),
secondarySubMode: config.secondarySub.defaultMode,
};
}
function getResolvedJellyfinConfig() {
return getResolvedConfig().jellyfin;
}
@@ -2084,6 +2171,7 @@ const startupState = runStartupBootstrapRuntime(
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
configHotReloadRuntime.start();
void refreshAnilistClientSecretState({ force: true });
},
getResolvedConfig: () => getResolvedConfig(),
@@ -2172,11 +2260,13 @@ const startupState = runStartupBootstrapRuntime(
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
shouldAutoInitializeOverlayRuntimeFromConfig(),
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
}),
onWillQuitCleanup: () => {
destroyTray();
configHotReloadRuntime.stop();
restorePreviousSecondarySubVisibility();
globalShortcut.unregisterAll();
subtitleWsService.stop();
@@ -2224,6 +2314,7 @@ const startupState = runStartupBootstrapRuntime(
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
overlayVisibilityRuntime.updateInvisibleOverlayVisibility();
},
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
}),
}),
);
@@ -2296,6 +2387,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
function handleInitialArgs(): void {
if (!appState.initialArgs) return;
if (appState.backgroundMode) {
ensureTray();
}
if (
!appState.texthookerOnlyMode &&
appState.immersionTracker &&
@@ -2529,6 +2623,103 @@ function createInvisibleWindow(): BrowserWindow {
return window;
}
function resolveTrayIconPath(): string | null {
const candidates = [
path.join(process.resourcesPath, 'assets', 'SubMiner.png'),
path.join(app.getAppPath(), 'assets', 'SubMiner.png'),
path.join(__dirname, '..', 'assets', 'SubMiner.png'),
path.join(__dirname, '..', '..', 'assets', 'SubMiner.png'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: 'Open Overlay',
click: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
},
},
{
label: 'Open Yomitan Settings',
click: () => {
openYomitanSettings();
},
},
{
label: 'Open Runtime Options',
click: () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
openRuntimeOptionsPalette();
},
},
{
label: 'Configure Jellyfin',
click: () => {
openJellyfinSetupWindow();
},
},
{
label: 'Configure AniList',
click: () => {
openAnilistSetupWindow();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
app.quit();
},
},
]);
}
function ensureTray(): void {
if (appTray) {
appTray.setContextMenu(buildTrayMenu());
return;
}
const iconPath = resolveTrayIconPath();
let trayIcon = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
if (trayIcon.isEmpty()) {
logger.warn('Tray icon asset not found; using empty icon placeholder.');
}
if (process.platform === 'linux' && !trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 20, height: 20 });
}
appTray = new Tray(trayIcon);
appTray.setToolTip(TRAY_TOOLTIP);
appTray.setContextMenu(buildTrayMenu());
appTray.on('click', () => {
if (!appState.overlayRuntimeInitialized) {
initializeOverlayRuntime();
}
setVisibleOverlayVisible(true);
});
}
function destroyTray(): void {
if (!appTray) {
return;
}
appTray.destroy();
appTray = null;
}
function initializeOverlayRuntime(): void {
if (appState.overlayRuntimeInitialized) {
return;
@@ -2600,6 +2791,12 @@ function registerGlobalShortcuts(): void {
});
}
function refreshGlobalAndOverlayShortcuts(): void {
globalShortcut.unregisterAll();
registerGlobalShortcuts();
syncOverlayShortcuts();
}
function getConfiguredShortcuts() {
return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG);
}
@@ -2916,17 +3113,7 @@ registerIpcRuntimeServices({
getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => {
const resolvedConfig = getResolvedConfig();
if (!resolvedConfig.subtitleStyle) {
return null;
}
return {
...resolvedConfig.subtitleStyle,
nPlusOneColor: resolvedConfig.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: resolvedConfig.ankiConnect.nPlusOne.knownWord,
enableJlpt: resolvedConfig.subtitleStyle.enableJlpt,
frequencyDictionary: resolvedConfig.subtitleStyle.frequencyDictionary,
};
return resolveSubtitleStyleForRenderer(resolvedConfig);
},
saveSubtitlePosition: (position: unknown) => saveSubtitlePosition(position as SubtitlePosition),
getMecabTokenizer: () => appState.mecabTokenizer,

View File

@@ -48,6 +48,7 @@ import type {
MpvSubtitleRenderMetrics,
OverlayContentMeasurement,
ShortcutsConfig,
ConfigHotReloadPayload,
} from './types';
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
@@ -236,6 +237,14 @@ const electronAPI: ElectronAPI = {
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
ipcRenderer.send('overlay-content-bounds:report', measurement);
},
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
ipcRenderer.on(
'config:hot-reload',
(_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => {
callback(payload);
},
);
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -185,13 +185,7 @@ export function createKeyboardHandlers(
}
async function setupMpvInputForwarding(): Promise<void> {
const keybindings: Keybinding[] = await window.electronAPI.getKeybindings();
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
updateKeybindings(await window.electronAPI.getKeybindings());
document.addEventListener('keydown', (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
@@ -293,7 +287,17 @@ export function createKeyboardHandlers(
});
}
function updateKeybindings(keybindings: Keybinding[]): void {
ctx.state.keybindingsMap = new Map();
for (const binding of keybindings) {
if (binding.command) {
ctx.state.keybindingsMap.set(binding.key, binding.command);
}
}
}
return {
setupMpvInputForwarding,
updateKeybindings,
};
}

View File

@@ -24,6 +24,7 @@ import type {
SubtitleData,
SubtitlePosition,
SubsyncManualPayload,
ConfigHotReloadPayload,
} from '../types';
import { createKeyboardHandlers } from './handlers/keyboard.js';
import { createMouseHandlers } from './handlers/mouse.js';
@@ -196,6 +197,12 @@ async function init(): Promise<void> {
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
runtimeOptionsModal.updateRuntimeOptions(options);
});
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
keyboardHandlers.updateKeybindings(payload.keybindings);
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode);
measurementReporter.schedule();
});
window.electronAPI.onOpenRuntimeOptions(() => {
runtimeOptionsModal.openRuntimeOptionsModal().catch(() => {
runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true);

View File

@@ -709,6 +709,12 @@ export type JimakuDownloadResult =
| { ok: true; path: string }
| { ok: false; error: JimakuApiError };
export interface ConfigHotReloadPayload {
keybindings: Keybinding[];
subtitleStyle: SubtitleStyleConfig | null;
secondarySubMode: SecondarySubMode;
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'invisible' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -763,6 +769,7 @@ export interface ElectronAPI {
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
}
declare global {