mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(config): hot-reload safe config updates and document behavior
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
src/core/services/config-hot-reload.test.ts
Normal file
111
src/core/services/config-hot-reload.test.ts
Normal 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);
|
||||
});
|
||||
159
src/core/services/config-hot-reload.ts
Normal file
159
src/core/services/config-hot-reload.ts
Normal 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 };
|
||||
@@ -108,3 +108,4 @@ export {
|
||||
createOverlayManager,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
} from './overlay-manager';
|
||||
export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload';
|
||||
|
||||
211
src/main.ts
211
src/main.ts
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user