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

@@ -18,6 +18,7 @@
- **Subtitle Download & Sync** — Search Jimaku, sync with alass or ffsubsync — all from the player
- **Queue Control In-Player** — Drop videos on overlay to load/queue in mpv; `Ctrl/Cmd+A` appends clipboard path
- **Keyboard-Driven** — Mine, copy, cycle display modes, and navigate from configurable shortcuts
- **Config Hot Reload** — Apply subtitle style and shortcut updates from `config.jsonc` without restarting
- **Japanese Tokenization** — MeCab-powered word boundary detection with smart grouping
## Requirements
@@ -68,6 +69,8 @@ For macOS builds and platform details, see the [installation docs](docs/installa
subminer video.mkv
```
Config tip: while SubMiner is running, safe config edits (subtitle style, keybindings, shortcuts, secondary subtitle default mode, and `ankiConnect.ai`) hot-reload automatically.
```bash
subminer # pick video from cwd (fzf)
subminer -R # rofi picker

View File

@@ -2,7 +2,8 @@
id: TASK-39
title: Add hot-reload for non-destructive config changes
status: Done
assignee: []
assignee:
- '@sudacode'
created_date: '2026-02-14 02:04'
updated_date: '2026-02-18 09:29'
labels:
@@ -10,6 +11,13 @@ labels:
- developer-experience
- quality-of-life
dependencies: []
references:
- docs/plans/2026-02-18-task-39-hot-reload-config.md
- README.md
- docs/configuration.md
- docs/usage.md
- config.example.jsonc
- docs/public/config.example.jsonc
priority: low
ordinal: 59000
---
@@ -58,3 +66,65 @@ Currently all config is loaded at startup. Users tweaking visual settings (font
- [x] #5 Invalid config changes are rejected with an error notification, keeping the previous valid config.
- [x] #6 Renderer receives updated styles/settings via IPC without full page reload.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Implementation plan documented in `docs/plans/2026-02-18-task-39-hot-reload-config.md`.
Execution breakdown:
1. Add strict config reload API in `ConfigService` so invalid JSON/JSONC updates are rejected without mutating current runtime config.
2. Add testable hot-reload coordinator service with debounced file-change handling and diff classification (hot-reload vs restart-required fields).
3. Wire coordinator into main process watcher lifecycle; apply hot changes (style/keybindings/shortcuts/secondary mode/anki AI), emit restart-needed notification for non-hot changes, and emit invalid-config notification.
4. Add renderer/preload live update IPC hooks for style/keybinding refresh without page reload.
5. Run build + targeted tests (`test:fast`) and validate criteria with notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented strict config reload path in `ConfigService` (`reloadConfigStrict`) to reject invalid JSON/JSONC while preserving the last valid in-memory config.
Added `createConfigHotReloadRuntime` with debounced watcher-driven reloads, hot-vs-restart diff classification, and invalid-config callback handling.
Wired runtime into main process startup/teardown with `fs.watch`-based config path monitoring (file or config dir fallback), hot update application, restart-needed notifications, and invalid-config notifications.
Added renderer IPC hot-update channel (`config:hot-reload`) through preload/types, updating keybindings map, subtitle style, and secondary subtitle mode without page reload.
Updated core/config tests and test runner list to cover strict reload behavior and new hot-reload runtime service.
Updated user-facing docs to describe hot-reload behavior and restart-required notifications in `README.md`, `docs/configuration.md`, and `docs/usage.md`.
Regenerated `config.example.jsonc` and docs-served `docs/public/config.example.jsonc` from updated template metadata so hot-reload notes appear inline in config sections.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented TASK-39 by introducing a debounced config hot-reload pipeline that watches config changes, safely reloads with strict JSON/JSONC validation, and applies non-destructive runtime updates without requiring a full app restart.
### What changed
- Added `reloadConfigStrict()` to `ConfigService` so malformed config edits are rejected and the previous valid runtime config remains active.
- Added new core runtime service `createConfigHotReloadRuntime` to:
- debounce rapid save bursts,
- classify changed fields into hot-reloadable vs restart-required,
- route invalid reload errors to user-visible notifications.
- Wired hot-reload runtime into `main.ts` app lifecycle:
- starts after initial config load,
- stops on app quit,
- applies hot updates for subtitle styling, keybindings, shortcuts, secondary subtitle mode defaults, and Anki AI config patching.
- Added renderer live-update IPC channel (`config:hot-reload`) via `preload.ts` + `types.ts`, and applied updates in renderer without page reload.
- Added/updated tests:
- strict reload behavior in `src/config/config.test.ts`,
- hot-reload diff/debounce/invalid handling in `src/core/services/config-hot-reload.test.ts`,
- included new test file in `test:core:dist` script.
### Validation
- `bun run build && node --test dist/config/config.test.js`
- `bun run build && node --test dist/core/services/config-hot-reload.test.js`
- `bun run test:fast`
All above commands passed.
Added follow-up documentation coverage for the shipped feature: README feature/quickstart note, configuration hot-reload behavior section, usage guide live-reload section, and regenerated config templates with per-section hot-reload notes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/
{
// ==========================================
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -23,7 +24,7 @@
// Control whether browser opens automatically for texthooker.
// ==========================================
"texthooker": {
"openBrowser": true,
"openBrowser": true
},
// ==========================================
@@ -33,7 +34,7 @@
// ==========================================
"websocket": {
"enabled": "auto",
"port": 6677,
"port": 6677
},
// ==========================================
@@ -42,12 +43,14 @@
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info",
"level": "info"
},
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: AI translation settings update live while SubMiner is running.
// Most other AnkiConnect settings still require restart.
// ==========================================
"ankiConnect": {
"enabled": false,
@@ -58,7 +61,7 @@
"image": "Picture",
"sentence": "Sentence",
"miscInfo": "MiscInfo",
"translation": "SelectionText",
"translation": "SelectionText"
},
"ai": {
"enabled": false,
@@ -67,7 +70,7 @@
"model": "openai/gpt-4o-mini",
"baseUrl": "https://openrouter.ai/api",
"targetLanguage": "English",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations."
},
"media": {
"generateAudio": true,
@@ -80,7 +83,7 @@
"animatedCrf": 35,
"audioPadding": 0.5,
"fallbackDuration": 3,
"maxMediaDuration": 30,
"maxMediaDuration": 30
},
"behavior": {
"overwriteAudio": true,
@@ -88,7 +91,7 @@
"mediaInsertMode": "append",
"highlightWord": true,
"notificationType": "osd",
"autoUpdateNewCards": true,
"autoUpdateNewCards": true
},
"nPlusOne": {
"highlightEnabled": false,
@@ -97,22 +100,22 @@
"decks": [],
"minSentenceWords": 3,
"nPlusOne": "#c6a0f6",
"knownWord": "#a6da95",
"knownWord": "#a6da95"
},
"metadata": {
"pattern": "[SubMiner] %f (%t)",
"pattern": "[SubMiner] %f (%t)"
},
"isLapis": {
"enabled": false,
"sentenceCardModel": "Japanese sentences",
"sentenceCardSentenceField": "Sentence",
"sentenceCardAudioField": "SentenceAudio",
"sentenceCardAudioField": "SentenceAudio"
},
"isKiku": {
"enabled": false,
"fieldGrouping": "disabled",
"deleteDuplicateInAuto": true,
},
"deleteDuplicateInAuto": true
}
},
// ==========================================
@@ -120,6 +123,7 @@
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
@@ -135,7 +139,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J",
"openJimaku": "Ctrl+Shift+J"
},
// ==========================================
@@ -145,19 +149,21 @@
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default",
"startupVisibility": "platform-default"
},
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [],
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false,
@@ -174,7 +180,7 @@
"N2": "#f5a97f",
"N3": "#f9e2af",
"N4": "#a6e3a1",
"N5": "#8aadf4",
"N5": "#8aadf4"
},
"frequencyDictionary": {
"enabled": false,
@@ -182,7 +188,13 @@
"topX": 1000,
"mode": "single",
"singleColor": "#f5a97f",
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
},
"secondary": {
"fontSize": 24,
@@ -190,19 +202,20 @@
"backgroundColor": "transparent",
"fontWeight": "normal",
"fontStyle": "normal",
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
},
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif"
}
},
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"secondarySubLanguages": [],
"autoLoadSecondarySub": false,
"defaultMode": "hover",
"defaultMode": "hover"
},
// ==========================================
@@ -213,7 +226,7 @@
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": "",
"ffmpeg_path": ""
},
// ==========================================
@@ -221,7 +234,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10,
"yPercent": 10
},
// ==========================================
@@ -231,7 +244,7 @@
"jimaku": {
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10,
"maxEntryResults": 10
},
// ==========================================
@@ -242,7 +255,10 @@
"mode": "automatic",
"whisperBin": "",
"whisperModel": "",
"primarySubLanguages": ["ja", "jpn"],
"primarySubLanguages": [
"ja",
"jpn"
]
},
// ==========================================
@@ -251,7 +267,7 @@
// ==========================================
"anilist": {
"enabled": false,
"accessToken": "",
"accessToken": ""
},
// ==========================================
@@ -276,8 +292,16 @@
"pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
"transcodeVideoCodec": "h264",
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
],
"transcodeVideoCodec": "h264"
},
// ==========================================
@@ -299,7 +323,7 @@
"telemetryDays": 30,
"dailyRollupsDays": 365,
"monthlyRollupsDays": 1825,
"vacuumIntervalDays": 7,
},
},
"vacuumIntervalDays": 7
}
}
}

View File

@@ -42,6 +42,25 @@ SubMiner.AppImage --generate-config --backup-overwrite
Invalid config values are handled with warn-and-fallback behavior: SubMiner logs the bad key/value and continues with the default for that option.
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
Hot-reloadable fields:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
Restart-required changes:
- Any other config sections still require restart.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview
The configuration file includes several main sections:
@@ -296,6 +315,8 @@ The list is generated at runtime from:
- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values).
- Current subtitle color settings from `subtitleStyle`.
When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries.
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/
{
// ==========================================
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -23,7 +24,7 @@
// Control whether browser opens automatically for texthooker.
// ==========================================
"texthooker": {
"openBrowser": true,
"openBrowser": true
},
// ==========================================
@@ -33,7 +34,7 @@
// ==========================================
"websocket": {
"enabled": "auto",
"port": 6677,
"port": 6677
},
// ==========================================
@@ -42,12 +43,14 @@
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info",
"level": "info"
},
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: AI translation settings update live while SubMiner is running.
// Most other AnkiConnect settings still require restart.
// ==========================================
"ankiConnect": {
"enabled": false,
@@ -58,7 +61,7 @@
"image": "Picture",
"sentence": "Sentence",
"miscInfo": "MiscInfo",
"translation": "SelectionText",
"translation": "SelectionText"
},
"ai": {
"enabled": false,
@@ -67,7 +70,7 @@
"model": "openai/gpt-4o-mini",
"baseUrl": "https://openrouter.ai/api",
"targetLanguage": "English",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.",
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations."
},
"media": {
"generateAudio": true,
@@ -80,7 +83,7 @@
"animatedCrf": 35,
"audioPadding": 0.5,
"fallbackDuration": 3,
"maxMediaDuration": 30,
"maxMediaDuration": 30
},
"behavior": {
"overwriteAudio": true,
@@ -88,7 +91,7 @@
"mediaInsertMode": "append",
"highlightWord": true,
"notificationType": "osd",
"autoUpdateNewCards": true,
"autoUpdateNewCards": true
},
"nPlusOne": {
"highlightEnabled": false,
@@ -97,22 +100,22 @@
"decks": [],
"minSentenceWords": 3,
"nPlusOne": "#c6a0f6",
"knownWord": "#a6da95",
"knownWord": "#a6da95"
},
"metadata": {
"pattern": "[SubMiner] %f (%t)",
"pattern": "[SubMiner] %f (%t)"
},
"isLapis": {
"enabled": false,
"sentenceCardModel": "Japanese sentences",
"sentenceCardSentenceField": "Sentence",
"sentenceCardAudioField": "SentenceAudio",
"sentenceCardAudioField": "SentenceAudio"
},
"isKiku": {
"enabled": false,
"fieldGrouping": "disabled",
"deleteDuplicateInAuto": true,
},
"deleteDuplicateInAuto": true
}
},
// ==========================================
@@ -120,6 +123,7 @@
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
// Fixed (non-configurable) overlay shortcuts:
// - Ctrl/Cmd+A: append clipboard video path to MPV playlist
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
@@ -135,7 +139,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
"openJimaku": "Ctrl+Shift+J",
"openJimaku": "Ctrl+Shift+J"
},
// ==========================================
@@ -145,19 +149,21 @@
// This edit-mode shortcut is fixed and is not currently configurable.
// ==========================================
"invisibleOverlay": {
"startupVisibility": "platform-default",
"startupVisibility": "platform-default"
},
// ==========================================
// Keybindings (MPV Commands)
// Extra keybindings that are merged with built-in defaults.
// Set command to null to disable a default keybinding.
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
// ==========================================
"keybindings": [],
// ==========================================
// Subtitle Appearance
// Primary and secondary subtitle styling.
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
// ==========================================
"subtitleStyle": {
"enableJlpt": false,
@@ -174,7 +180,7 @@
"N2": "#f5a97f",
"N3": "#f9e2af",
"N4": "#a6e3a1",
"N5": "#8aadf4",
"N5": "#8aadf4"
},
"frequencyDictionary": {
"enabled": false,
@@ -182,7 +188,13 @@
"topX": 1000,
"mode": "single",
"singleColor": "#f5a97f",
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
},
"secondary": {
"fontSize": 24,
@@ -190,19 +202,20 @@
"backgroundColor": "transparent",
"fontWeight": "normal",
"fontStyle": "normal",
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
},
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif"
}
},
// ==========================================
// Secondary Subtitles
// Dual subtitle track options.
// Used by subminer YouTube subtitle generation as secondary language preferences.
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"secondarySubLanguages": [],
"autoLoadSecondarySub": false,
"defaultMode": "hover",
"defaultMode": "hover"
},
// ==========================================
@@ -213,7 +226,7 @@
"defaultMode": "auto",
"alass_path": "",
"ffsubsync_path": "",
"ffmpeg_path": "",
"ffmpeg_path": ""
},
// ==========================================
@@ -221,7 +234,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10,
"yPercent": 10
},
// ==========================================
@@ -231,7 +244,7 @@
"jimaku": {
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10,
"maxEntryResults": 10
},
// ==========================================
@@ -242,7 +255,10 @@
"mode": "automatic",
"whisperBin": "",
"whisperModel": "",
"primarySubLanguages": ["ja", "jpn"],
"primarySubLanguages": [
"ja",
"jpn"
]
},
// ==========================================
@@ -251,7 +267,7 @@
// ==========================================
"anilist": {
"enabled": false,
"accessToken": "",
"accessToken": ""
},
// ==========================================
@@ -276,8 +292,16 @@
"pullPictures": false,
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
"directPlayPreferred": true,
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
"transcodeVideoCodec": "h264",
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
],
"transcodeVideoCodec": "h264"
},
// ==========================================
@@ -299,7 +323,7 @@
"telemetryDays": 30,
"dailyRollupsDays": 365,
"monthlyRollupsDays": 1825,
"vacuumIntervalDays": 7,
},
},
"vacuumIntervalDays": 7
}
}
}

View File

@@ -11,6 +11,21 @@ You can use both together—install the plugin for on-demand control, but use `s
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
## Live Config Reload
While SubMiner is running, it watches your active config file and applies safe updates automatically.
Live-updated settings:
- `subtitleStyle`
- `keybindings`
- `shortcuts`
- `secondarySub.defaultMode`
- `ankiConnect.ai`
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
For restart-required sections, SubMiner shows a restart-needed notification.
## Commands
```bash
@@ -46,6 +61,7 @@ subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directo
subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback
# Direct AppImage control
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --stop # Stop overlay
@@ -73,6 +89,8 @@ SubMiner.AppImage --help # Show all options
- `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
- Linux desktop launcher starts SubMiner with `--background` by default.
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
### Launcher Subcommands

View File

@@ -17,7 +17,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:config:dist": "node --test dist/config/config.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "bun run test:config && bun run test:core",
"test:config": "bun run build && bun run test:config:dist",
@@ -77,7 +77,10 @@
"target": [
"AppImage"
],
"category": "AudioVideo"
"category": "AudioVideo",
"executableArgs": [
"--background"
]
},
"mac": {
"target": [

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 {