From 4703b995da419a62666a66d7dc4409c2418b550d Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Feb 2026 01:04:56 -0800 Subject: [PATCH] feat(config): hot-reload safe config updates and document behavior --- README.md | 3 + ...load-for-non-destructive-config-changes.md | 72 +++++- config.example.jsonc | 82 ++++--- docs/configuration.md | 21 ++ docs/public/config.example.jsonc | 82 ++++--- docs/usage.md | 18 ++ package.json | 7 +- src/config/config.test.ts | 49 ++++ src/config/definitions.ts | 10 + src/config/service.ts | 68 +++++- src/core/services/config-hot-reload.test.ts | 111 +++++++++ src/core/services/config-hot-reload.ts | 159 +++++++++++++ src/core/services/index.ts | 1 + src/main.ts | 211 +++++++++++++++++- src/preload.ts | 9 + src/renderer/handlers/keyboard.ts | 18 +- src/renderer/renderer.ts | 7 + src/types.ts | 7 + 18 files changed, 850 insertions(+), 85 deletions(-) create mode 100644 src/core/services/config-hot-reload.test.ts create mode 100644 src/core/services/config-hot-reload.ts diff --git a/README.md b/README.md index acd713c..e01cff2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md b/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md index 79b3089..762b798 100644 --- a/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md +++ b/backlog/tasks/task-39 - Add-hot-reload-for-non-destructive-config-changes.md @@ -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. + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/config.example.jsonc b/config.example.jsonc index 339f0bd..2fa90c3 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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 + } + } } diff --git a/docs/configuration.md b/docs/configuration.md index 05a243f..193dd06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index 339f0bd..2fa90c3 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -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 + } + } } diff --git a/docs/usage.md b/docs/usage.md index e29ee98..9887677 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/package.json b/package.json index 7061d80..c53dfd7 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 51a94e2..f0cbf4c 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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( diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 0b111fc..10cc56f 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -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', }, { diff --git a/src/config/service.ts b/src/config/service.ts index 10fc61e..35e611f 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -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 { 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 }; } } diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts new file mode 100644 index 0000000..f18d26b --- /dev/null +++ b/src/core/services/config-hot-reload.test.ts @@ -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 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); +}); diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts new file mode 100644 index 0000000..b122fb9 --- /dev/null +++ b/src/core/services/config-hot-reload.ts @@ -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), + ...(Object.keys(next) as Array), + ]); + + 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 }; diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 6525976..4b5252c 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -108,3 +108,4 @@ export { createOverlayManager, setOverlayDebugVisualizationEnabledRuntime, } from './overlay-manager'; +export { createConfigHotReloadRuntime, classifyConfigHotReloadDiff } from './config-hot-reload'; diff --git a/src/main.ts b/src/main.ts index 8c99ce0..aa1b142 100644 --- a/src/main.ts +++ b/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, diff --git a/src/preload.ts b/src/preload.ts index a02c605..79b58f2 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 9c2ceee..c177222 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -185,13 +185,7 @@ export function createKeyboardHandlers( } async function setupMpvInputForwarding(): Promise { - 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, }; } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index c13ec6b..58f9617 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 { 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); diff --git a/src/types.ts b/src/types.ts index 4745935..7e37c98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; + onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; } declare global {