fix: Kiku field grouping, frequency particles, sidebar media, Yomitan po

- Fix Kiku duplicate-card field grouping: local dupes trigger manual modal or auto-merge; fix modal-open ack race; fix merged field ordering, sentence-audio, furigana, and tag semantics
- Fix frequency annotations for single-token Yomitan compounds with internal particles (e.g. 目の前); keep pure grammar/kana spans unannotated
- Fix subtitle sidebar mining: use audio/image from clicked sidebar line, not current primary line
- Add `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitle visible while Yomitan popup is open
- Normalize trailing commas in config.example.jsonc
This commit is contained in:
2026-05-27 00:12:21 -07:00
parent 5b44981688
commit eb04ea97b1
49 changed files with 1711 additions and 662 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
+1
View File
@@ -0,0 +1 @@
- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: config
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line.
+83 -148
View File
@@ -5,7 +5,6 @@
* Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.
*/ */
{ {
// ========================================== // ==========================================
// Visible Overlay Auto-Start // Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
@@ -19,7 +18,7 @@
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior. }, // Configure texthooker startup launch and browser opening behavior.
// ========================================== // ==========================================
@@ -29,7 +28,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677 // Built-in subtitle websocket server port. "port": 6677, // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients. }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
@@ -39,7 +38,7 @@
// ========================================== // ==========================================
"annotationWebsocket": { "annotationWebsocket": {
"enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false
"port": 6678 // Annotated subtitle websocket server port. "port": 6678, // Annotated subtitle websocket server port.
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
// ========================================== // ==========================================
@@ -54,8 +53,8 @@
"files": { "files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false "app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false "launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false "mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting. }, // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -88,66 +87,66 @@
"leftStickPress": 9, // Raw button index used for controller L3 input. "leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7, // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action. "buttonIndex": 0, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": { "closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action. "buttonIndex": 1, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": { "toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action. "buttonIndex": 3, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": { "mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action. "buttonIndex": 2, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": { "quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action. "buttonIndex": 6, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": { "previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": { "nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action. "buttonIndex": 5, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": { "playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action. "buttonIndex": 4, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": { "toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action. "buttonIndex": 9, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "axisIndex": 0, // Raw axis index captured for this analog controller action.
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
"leftStickVertical": { "leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 1, // Raw axis index captured for this analog controller action. "axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": { "rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action. "axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": { "rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action. "axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active. }, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================
@@ -161,7 +160,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ========================================== // ==========================================
@@ -173,7 +172,7 @@
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable", // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
// ========================================== // ==========================================
@@ -199,7 +198,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -211,122 +210,76 @@
"keybindings": [ "keybindings": [
{ {
"key": "Space", // Key setting. "key": "Space", // Key setting.
"command": [ "command": ["cycle", "pause"], // Command setting.
"cycle",
"pause"
] // Command setting.
}, },
{ {
"key": "KeyF", // Key setting. "key": "KeyF", // Key setting.
"command": [ "command": ["cycle", "fullscreen"], // Command setting.
"cycle",
"fullscreen"
] // Command setting.
}, },
{ {
"key": "KeyJ", // Key setting. "key": "KeyJ", // Key setting.
"command": [ "command": ["cycle", "sid"], // Command setting.
"cycle",
"sid"
] // Command setting.
}, },
{ {
"key": "Shift+KeyJ", // Key setting. "key": "Shift+KeyJ", // Key setting.
"command": [ "command": ["cycle", "secondary-sid"], // Command setting.
"cycle",
"secondary-sid"
] // Command setting.
}, },
{ {
"key": "ArrowRight", // Key setting. "key": "ArrowRight", // Key setting.
"command": [ "command": ["seek", 5], // Command setting.
"seek",
5
] // Command setting.
}, },
{ {
"key": "ArrowLeft", // Key setting. "key": "ArrowLeft", // Key setting.
"command": [ "command": ["seek", -5], // Command setting.
"seek",
-5
] // Command setting.
}, },
{ {
"key": "ArrowUp", // Key setting. "key": "ArrowUp", // Key setting.
"command": [ "command": ["seek", 60], // Command setting.
"seek",
60
] // Command setting.
}, },
{ {
"key": "ArrowDown", // Key setting. "key": "ArrowDown", // Key setting.
"command": [ "command": ["seek", -60], // Command setting.
"seek",
-60
] // Command setting.
}, },
{ {
"key": "Shift+KeyH", // Key setting. "key": "Shift+KeyH", // Key setting.
"command": [ "command": ["sub-seek", -1], // Command setting.
"sub-seek",
-1
] // Command setting.
}, },
{ {
"key": "Shift+KeyL", // Key setting. "key": "Shift+KeyL", // Key setting.
"command": [ "command": ["sub-seek", 1], // Command setting.
"sub-seek",
1
] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Shift+BracketRight", // Key setting.
"command": [ "command": ["__sub-delay-next-line"], // Command setting.
"__sub-delay-next-line"
] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Shift+BracketLeft", // Key setting.
"command": [ "command": ["__sub-delay-prev-line"], // Command setting.
"__sub-delay-prev-line"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyC", // Key setting. "key": "Ctrl+Alt+KeyC", // Key setting.
"command": [ "command": ["__youtube-picker-open"], // Command setting.
"__youtube-picker-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyP", // Key setting. "key": "Ctrl+Alt+KeyP", // Key setting.
"command": [ "command": ["__playlist-browser-open"], // Command setting.
"__playlist-browser-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": [ "command": ["__replay-subtitle"], // Command setting.
"__replay-subtitle"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyL", // Key setting. "key": "Ctrl+Shift+KeyL", // Key setting.
"command": [ "command": ["__play-next-subtitle"], // Command setting.
"__play-next-subtitle"
] // Command setting.
}, },
{ {
"key": "KeyQ", // Key setting. "key": "KeyQ", // Key setting.
"command": [ "command": ["quit"], // Command setting.
"quit"
] // Command setting.
}, },
{ {
"key": "Ctrl+KeyW", // Key setting. "key": "Ctrl+KeyW", // Key setting.
"command": [ "command": ["quit"], // Command setting.
"quit" },
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults. ], // Default and custom keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
@@ -338,7 +291,7 @@
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
@@ -350,7 +303,7 @@
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false "replace": true, // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -358,7 +311,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
}, // Initial vertical subtitle position from the bottom. }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
@@ -383,12 +336,13 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent", // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults. }, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
@@ -399,7 +353,7 @@
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting. "N3": "#f9e2af", // N3 setting.
"N4": "#8bd5ca", // N4 setting. "N4": "#8bd5ca", // N4 setting.
"N5": "#8aadf4" // N5 setting. "N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting. }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
@@ -408,13 +362,7 @@
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": [ "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": { "css": {
@@ -430,9 +378,9 @@
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. }, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. }, // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
@@ -457,8 +405,8 @@
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting. "--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting. "--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting. "--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting. "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)", // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. }, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ========================================== // ==========================================
@@ -472,7 +420,7 @@
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests.
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
// ========================================== // ==========================================
@@ -490,11 +438,9 @@
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting. }, // Proxy setting.
"tags": [ "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
@@ -502,12 +448,12 @@
"image": "Picture", // Card field that receives the captured screenshot or animated image. "image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text. "sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
"translation": "SelectionText" // Card field that receives the current selection or translated text. "translation": "SelectionText", // Card field that receives the current selection or translated text.
}, // Fields setting. }, // Fields setting.
"ai": { "ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
"model": "", // Optional model override for Anki AI translation/enrichment flows. "model": "", // Optional model override for Anki AI translation/enrichment flows.
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows.
}, // Ai setting. }, // Ai setting.
"media": { "media": {
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
@@ -524,14 +470,14 @@
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. "decks": {}, // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -539,24 +485,24 @@
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting. }, // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
@@ -569,7 +515,7 @@
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc. "apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text. "apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned. "maxEntryResults": 10, // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
@@ -578,10 +524,7 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": [ "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
@@ -599,9 +542,9 @@
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting. }, // Collapsible sections setting.
} // Character dictionary setting. }, // Character dictionary setting.
}, // Anilist API credentials and update behavior. }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
@@ -612,7 +555,7 @@
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ========================================== // ==========================================
"yomitan": { "yomitan": {
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration. }, // Optional external Yomitan profile integration.
// ========================================== // ==========================================
@@ -634,7 +577,7 @@
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible.
}, // SubMiner-managed mpv launch and bundled plugin options. }, // SubMiner-managed mpv launch and bundled plugin options.
// ========================================== // ==========================================
@@ -655,16 +598,8 @@
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [ "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"mkv", "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -676,7 +611,7 @@
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates. "debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
@@ -701,13 +636,13 @@
"sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "sessionsDays": 0, // Session retention window in days. Use 0 to keep all.
"dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all.
"monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all.
"vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable.
}, // Retention setting. }, // Retention setting.
"lifetimeSummaries": { "lifetimeSummaries": {
"global": true, // Maintain global lifetime stats rows. Values: true | false "global": true, // Maintain global lifetime stats rows. Values: true | false
"anime": true, // Maintain per-anime lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false
"media": true // Maintain per-media lifetime stats rows. Values: true | false "media": true, // Maintain per-media lifetime stats rows. Values: true | false
} // Lifetime summaries setting. }, // Lifetime summaries setting.
}, // Enable/disable immersion tracking. }, // Enable/disable immersion tracking.
// ========================================== // ==========================================
@@ -720,6 +655,6 @@
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
"serverPort": 6969, // Port for the stats HTTP server. "serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay. }, // Local immersion stats dashboard served on localhost and available as an in-app overlay.
} }
+157 -156
View File
@@ -272,8 +272,8 @@ See `config.example.jsonc` for detailed configuration options.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- | | ------------------- | ------------------------- | --------------------------------------------------- |
| `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) | | `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `websocket.port` | number | WebSocket server port (default: 6677) | | `websocket.port` | number | WebSocket server port (default: 6677) |
@@ -292,8 +292,8 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | --------------- | -------------------------------------------------------------- | | ----------------------------- | --------------- | -------------------------------------------------------------- |
| `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
| `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) | | `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) |
@@ -358,34 +358,35 @@ See `config.example.jsonc` for detailed configuration options.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. | | `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. | | `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | | `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) | | `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) | | `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | | `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | | `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | | `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
Subtitle CSS custom properties: Subtitle CSS custom properties:
| CSS Property | Default | Description | | CSS Property | Default | Description |
| --------------------------------------------- | ------------- | ---------------------------------------- | | ----------------------------------------- | ------------- | --------------------------------------- |
| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color | | `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color |
| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color | | `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color |
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
@@ -438,25 +439,25 @@ Configure the parsed-subtitle sidebar modal.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------- | | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) | | `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `subtitleSidebar.toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `subtitleSidebar.toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) | | `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
| `autoScroll` | boolean | Keep the active cue in view while playback advances | | `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. | | `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. |
Sidebar CSS custom properties: Sidebar CSS custom properties:
| CSS Property | Default | Description | | CSS Property | Default | Description |
| ------------------------------------------------- | ------------------------------- | ------------------------------------- | | -------------------------------------------- | --------------------------- | ---------------------------- |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | | `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | | `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color |
| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | | `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color |
| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color | | `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color |
| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | | `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog.
@@ -621,26 +622,26 @@ See `config.example.jsonc` for detailed configuration options.
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | | `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | | `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | | `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | | `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | | `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | | `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | | `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | | `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | | `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | | `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | | `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options. **See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -768,19 +769,19 @@ Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing,
When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
| Shortcut | Action | | Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ | | -------------- | ------------------------------------------------------------------------------------------------------------- |
| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | | `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) |
| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | | `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds |
| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | | `Ctrl+V` | Update the last added Anki card using subtitles from clipboard |
| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) | | `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) |
| `Ctrl+S` | Create a sentence card from the current subtitle line | | `Ctrl+S` | Create a sentence card from the current subtitle line |
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+D` | Open loaded character dictionary manager | | `Ctrl+D` | Open loaded character dictionary manager |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
**Multi-line copy workflow:** **Multi-line copy workflow:**
@@ -855,7 +856,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
| Option | Values | Description | | Option | Values | Description |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ | | ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
| `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) | | `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
| `apiKey` | string | Static API key for the shared provider | | `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) | | `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) | | `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
@@ -939,57 +940,57 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | | `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | | `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | | `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | | `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | | `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | | `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | | `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | | `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | | `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | | `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | | `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | | `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | | `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | | `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | | `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | | `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | | `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). | | `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | | `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | | `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | | `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1144,15 +1145,15 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | | `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile | | `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
@@ -1243,21 +1244,21 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ |
| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | | `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | | `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) |
| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | | `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) |
| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | | `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | | `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | | `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
@@ -1295,12 +1296,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
} }
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | | ------------------------- | ------------------------------------------------ | ---------------------------------------------------------- |
| `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | | `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | | `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps: Setup steps:
+83 -148
View File
@@ -5,7 +5,6 @@
* Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.
*/ */
{ {
// ========================================== // ==========================================
// Visible Overlay Auto-Start // Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
@@ -19,7 +18,7 @@
// ========================================== // ==========================================
"texthooker": { "texthooker": {
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior. }, // Configure texthooker startup launch and browser opening behavior.
// ========================================== // ==========================================
@@ -29,7 +28,7 @@
// ========================================== // ==========================================
"websocket": { "websocket": {
"enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677 // Built-in subtitle websocket server port. "port": 6677, // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients. }, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ========================================== // ==========================================
@@ -39,7 +38,7 @@
// ========================================== // ==========================================
"annotationWebsocket": { "annotationWebsocket": {
"enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false
"port": 6678 // Annotated subtitle websocket server port. "port": 6678, // Annotated subtitle websocket server port.
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
// ========================================== // ==========================================
@@ -54,8 +53,8 @@
"files": { "files": {
"app": true, // Write SubMiner app runtime logs. Values: true | false "app": true, // Write SubMiner app runtime logs. Values: true | false
"launcher": true, // Write launcher command logs. Values: true | false "launcher": true, // Write launcher command logs. Values: true | false
"mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false "mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
} // Files setting. }, // Files setting.
}, // Controls logging verbosity. }, // Controls logging verbosity.
// ========================================== // ==========================================
@@ -88,66 +87,66 @@
"leftStickPress": 9, // Raw button index used for controller L3 input. "leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 input. "rightStickPress": 10, // Raw button index used for controller R3 input.
"leftTrigger": 6, // Raw button index used for controller L2 input. "leftTrigger": 6, // Raw button index used for controller L2 input.
"rightTrigger": 7 // Raw button index used for controller R2 input. "rightTrigger": 7, // Raw button index used for controller R2 input.
}, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
"bindings": { "bindings": {
"toggleLookup": { "toggleLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 0 // Raw button index captured for this discrete controller action. "buttonIndex": 0, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"closeLookup": { "closeLookup": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 1 // Raw button index captured for this discrete controller action. "buttonIndex": 1, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleKeyboardOnlyMode": { "toggleKeyboardOnlyMode": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 3 // Raw button index captured for this discrete controller action. "buttonIndex": 3, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"mineCard": { "mineCard": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 2 // Raw button index captured for this discrete controller action. "buttonIndex": 2, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"quitMpv": { "quitMpv": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 6 // Raw button index captured for this discrete controller action. "buttonIndex": 6, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"previousAudio": { "previousAudio": {
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"nextAudio": { "nextAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 5 // Raw button index captured for this discrete controller action. "buttonIndex": 5, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"playCurrentAudio": { "playCurrentAudio": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 4 // Raw button index captured for this discrete controller action. "buttonIndex": 4, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"toggleMpvPause": { "toggleMpvPause": {
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
"buttonIndex": 9 // Raw button index captured for this discrete controller action. "buttonIndex": 9, // Raw button index captured for this discrete controller action.
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
"leftStickHorizontal": { "leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 0, // Raw axis index captured for this analog controller action. "axisIndex": 0, // Raw axis index captured for this analog controller action.
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
"leftStickVertical": { "leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 1, // Raw axis index captured for this analog controller action. "axisIndex": 1, // Raw axis index captured for this analog controller action.
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickHorizontal": { "rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 3, // Raw axis index captured for this analog controller action. "axisIndex": 3, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
"rightStickVertical": { "rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis "kind": "axis", // Analog binding input source kind. Values: none | axis
"axisIndex": 4, // Raw axis index captured for this analog controller action. "axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active. }, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ========================================== // ==========================================
@@ -161,7 +160,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ========================================== // ==========================================
@@ -173,7 +172,7 @@
"enabled": true, // Run automatic update checks in the background. Values: true | false "enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks. "checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease "channel": "stable", // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior. }, // Automatic update check behavior.
// ========================================== // ==========================================
@@ -199,7 +198,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -211,122 +210,76 @@
"keybindings": [ "keybindings": [
{ {
"key": "Space", // Key setting. "key": "Space", // Key setting.
"command": [ "command": ["cycle", "pause"], // Command setting.
"cycle",
"pause"
] // Command setting.
}, },
{ {
"key": "KeyF", // Key setting. "key": "KeyF", // Key setting.
"command": [ "command": ["cycle", "fullscreen"], // Command setting.
"cycle",
"fullscreen"
] // Command setting.
}, },
{ {
"key": "KeyJ", // Key setting. "key": "KeyJ", // Key setting.
"command": [ "command": ["cycle", "sid"], // Command setting.
"cycle",
"sid"
] // Command setting.
}, },
{ {
"key": "Shift+KeyJ", // Key setting. "key": "Shift+KeyJ", // Key setting.
"command": [ "command": ["cycle", "secondary-sid"], // Command setting.
"cycle",
"secondary-sid"
] // Command setting.
}, },
{ {
"key": "ArrowRight", // Key setting. "key": "ArrowRight", // Key setting.
"command": [ "command": ["seek", 5], // Command setting.
"seek",
5
] // Command setting.
}, },
{ {
"key": "ArrowLeft", // Key setting. "key": "ArrowLeft", // Key setting.
"command": [ "command": ["seek", -5], // Command setting.
"seek",
-5
] // Command setting.
}, },
{ {
"key": "ArrowUp", // Key setting. "key": "ArrowUp", // Key setting.
"command": [ "command": ["seek", 60], // Command setting.
"seek",
60
] // Command setting.
}, },
{ {
"key": "ArrowDown", // Key setting. "key": "ArrowDown", // Key setting.
"command": [ "command": ["seek", -60], // Command setting.
"seek",
-60
] // Command setting.
}, },
{ {
"key": "Shift+KeyH", // Key setting. "key": "Shift+KeyH", // Key setting.
"command": [ "command": ["sub-seek", -1], // Command setting.
"sub-seek",
-1
] // Command setting.
}, },
{ {
"key": "Shift+KeyL", // Key setting. "key": "Shift+KeyL", // Key setting.
"command": [ "command": ["sub-seek", 1], // Command setting.
"sub-seek",
1
] // Command setting.
}, },
{ {
"key": "Shift+BracketRight", // Key setting. "key": "Shift+BracketRight", // Key setting.
"command": [ "command": ["__sub-delay-next-line"], // Command setting.
"__sub-delay-next-line"
] // Command setting.
}, },
{ {
"key": "Shift+BracketLeft", // Key setting. "key": "Shift+BracketLeft", // Key setting.
"command": [ "command": ["__sub-delay-prev-line"], // Command setting.
"__sub-delay-prev-line"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyC", // Key setting. "key": "Ctrl+Alt+KeyC", // Key setting.
"command": [ "command": ["__youtube-picker-open"], // Command setting.
"__youtube-picker-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Alt+KeyP", // Key setting. "key": "Ctrl+Alt+KeyP", // Key setting.
"command": [ "command": ["__playlist-browser-open"], // Command setting.
"__playlist-browser-open"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": [ "command": ["__replay-subtitle"], // Command setting.
"__replay-subtitle"
] // Command setting.
}, },
{ {
"key": "Ctrl+Shift+KeyL", // Key setting. "key": "Ctrl+Shift+KeyL", // Key setting.
"command": [ "command": ["__play-next-subtitle"], // Command setting.
"__play-next-subtitle"
] // Command setting.
}, },
{ {
"key": "KeyQ", // Key setting. "key": "KeyQ", // Key setting.
"command": [ "command": ["quit"], // Command setting.
"quit"
] // Command setting.
}, },
{ {
"key": "Ctrl+KeyW", // Key setting. "key": "Ctrl+KeyW", // Key setting.
"command": [ "command": ["quit"], // Command setting.
"quit" },
] // Command setting.
}
], // Default and custom keybindings that are merged with built-in defaults. ], // Default and custom keybindings that are merged with built-in defaults.
// ========================================== // ==========================================
@@ -338,7 +291,7 @@
"secondarySub": { "secondarySub": {
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
@@ -350,7 +303,7 @@
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false "replace": true, // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================
@@ -358,7 +311,7 @@
// Initial vertical subtitle position from the bottom. // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
"subtitlePosition": { "subtitlePosition": {
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
}, // Initial vertical subtitle position from the bottom. }, // Initial vertical subtitle position from the bottom.
// ========================================== // ==========================================
@@ -383,12 +336,13 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent", // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults. }, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
@@ -399,7 +353,7 @@
"N2": "#f5a97f", // N2 setting. "N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting. "N3": "#f9e2af", // N3 setting.
"N4": "#8bd5ca", // N4 setting. "N4": "#8bd5ca", // N4 setting.
"N5": "#8aadf4" // N5 setting. "N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting. }, // Jlpt colors setting.
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
@@ -408,13 +362,7 @@
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": [ "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting. }, // Frequency dictionary setting.
"secondary": { "secondary": {
"css": { "css": {
@@ -430,9 +378,9 @@
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. }, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. }, // Secondary setting.
}, // Primary and secondary subtitle styling. }, // Primary and secondary subtitle styling.
// ========================================== // ==========================================
@@ -457,8 +405,8 @@
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting. "--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting. "--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting. "--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting. "--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)", // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. }, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key. }, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ========================================== // ==========================================
@@ -472,7 +420,7 @@
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests.
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
// ========================================== // ==========================================
@@ -490,11 +438,9 @@
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting. }, // Proxy setting.
"tags": [ "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
@@ -502,12 +448,12 @@
"image": "Picture", // Card field that receives the captured screenshot or animated image. "image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text. "sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
"translation": "SelectionText" // Card field that receives the current selection or translated text. "translation": "SelectionText", // Card field that receives the current selection or translated text.
}, // Fields setting. }, // Fields setting.
"ai": { "ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
"model": "", // Optional model override for Anki AI translation/enrichment flows. "model": "", // Optional model override for Anki AI translation/enrichment flows.
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows.
}, // Ai setting. }, // Ai setting.
"media": { "media": {
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
@@ -524,14 +470,14 @@
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. "decks": {}, // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
}, // Known words setting. }, // Known words setting.
"behavior": { "behavior": {
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
@@ -539,24 +485,24 @@
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
}, // Behavior setting. }, // Behavior setting.
"nPlusOne": { "nPlusOne": {
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
}, // N plus one setting. }, // N plus one setting.
"metadata": { "metadata": {
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting. }, // Metadata setting.
"isLapis": { "isLapis": {
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards.
}, // Is lapis setting. }, // Is lapis setting.
"isKiku": { "isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting. }, // Is kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ========================================== // ==========================================
@@ -569,7 +515,7 @@
"apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc. "apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc.
"apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text. "apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned. "maxEntryResults": 10, // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
@@ -578,10 +524,7 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": [ "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ========================================== // ==========================================
@@ -599,9 +542,9 @@
"collapsibleSections": { "collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting. }, // Collapsible sections setting.
} // Character dictionary setting. }, // Character dictionary setting.
}, // Anilist API credentials and update behavior. }, // Anilist API credentials and update behavior.
// ========================================== // ==========================================
@@ -612,7 +555,7 @@
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ========================================== // ==========================================
"yomitan": { "yomitan": {
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration. }, // Optional external Yomitan profile integration.
// ========================================== // ==========================================
@@ -634,7 +577,7 @@
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible.
}, // SubMiner-managed mpv launch and bundled plugin options. }, // SubMiner-managed mpv launch and bundled plugin options.
// ========================================== // ==========================================
@@ -655,16 +598,8 @@
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [ "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"mkv", "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch. }, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ========================================== // ==========================================
@@ -676,7 +611,7 @@
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
"updateIntervalMs": 3000, // Minimum interval between presence payload updates. "updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates. "debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session. }, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ========================================== // ==========================================
@@ -701,13 +636,13 @@
"sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "sessionsDays": 0, // Session retention window in days. Use 0 to keep all.
"dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all.
"monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all.
"vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable.
}, // Retention setting. }, // Retention setting.
"lifetimeSummaries": { "lifetimeSummaries": {
"global": true, // Maintain global lifetime stats rows. Values: true | false "global": true, // Maintain global lifetime stats rows. Values: true | false
"anime": true, // Maintain per-anime lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false
"media": true // Maintain per-media lifetime stats rows. Values: true | false "media": true, // Maintain per-media lifetime stats rows. Values: true | false
} // Lifetime summaries setting. }, // Lifetime summaries setting.
}, // Enable/disable immersion tracking. }, // Enable/disable immersion tracking.
// ========================================== // ==========================================
@@ -720,6 +655,6 @@
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
"serverPort": 6969, // Port for the stats HTTP server. "serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay. }, // Local immersion stats dashboard served on localhost and available as an in-app overlay.
} }
+59 -8
View File
@@ -288,6 +288,48 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
assert.equal(privateState.runtime.proxyServer, null); assert.equal(privateState.runtime.proxyServer, null);
}); });
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
const integration = new AnkiIntegration(
{
isKiku: {
enabled: true,
fieldGrouping: 'manual',
},
} as never,
{} as never,
{} as never,
);
let groupingTriggered = 0;
const internals = integration as unknown as {
cardCreationService: {
createSentenceCard: (
sentence: string,
startTime: number,
endTime: number,
secondarySubText?: string,
) => Promise<boolean>;
};
fieldGroupingService: {
triggerFieldGroupingForLastAddedCard: () => Promise<void>;
};
};
internals.cardCreationService = {
createSentenceCard: async () => {
integration.trackDuplicateNoteIdsForNote(42, [7]);
return true;
},
};
internals.fieldGroupingService = {
triggerFieldGroupingForLastAddedCard: async () => {
groupingTriggered += 1;
},
};
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
assert.equal(groupingTriggered, 1);
});
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
const osdMessages: string[] = []; const osdMessages: string[] = [];
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
@@ -316,7 +358,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
}); });
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator(); const collaborator = createFieldGroupingMergeCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields( const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -340,9 +382,9 @@ test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged Se
assert.equal( assert.equal(
merged.SentenceAudio, merged.SentenceAudio,
'<span data-group-id="101">[sound:keep.mp3]</span><span data-group-id="202">[sound:new.mp3]</span>', '<span data-group-id="202">[sound:new.mp3]</span><span data-group-id="101">[sound:keep.mp3]</span>',
); );
assert.equal(merged.ExpressionAudio, merged.SentenceAudio); assert.equal('ExpressionAudio' in merged, false);
}); });
test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => {
@@ -374,7 +416,7 @@ test('FieldGroupingMergeCollaborator uses generated media fallback when source l
assert.equal(merged.SentenceAudio, '<span data-group-id="22">[sound:generated.mp3]</span>'); assert.equal(merged.SentenceAudio, '<span data-group-id="22">[sound:generated.mp3]</span>');
}); });
test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and image values when merging into a new duplicate card', async () => { test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => {
const collaborator = createFieldGroupingMergeCollaborator(); const collaborator = createFieldGroupingMergeCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields( const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -400,10 +442,19 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
false, false,
); );
assert.equal(merged.Sentence, '<span data-group-id="202">same sentence</span>'); assert.equal(
assert.equal(merged.SentenceAudio, '<span data-group-id="202">[sound:same.mp3]</span>'); merged.Sentence,
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">'); '<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
assert.equal(merged.ExpressionAudio, merged.SentenceAudio); );
assert.equal(
merged.SentenceAudio,
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
);
assert.equal('ExpressionAudio' in merged, false);
}); });
test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
+102 -27
View File
@@ -29,7 +29,7 @@ import {
} from './types/anki'; } from './types/anki';
import { AiConfig } from './types/integrations'; import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime'; import { MpvClient } from './types/runtime';
import { NPlusOneMatchMode } from './types/subtitle'; import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import { import {
getConfiguredWordFieldCandidates, getConfiguredWordFieldCandidates,
@@ -149,6 +149,7 @@ export class AnkiIntegration {
private aiConfig: AiConfig; private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>(); private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>(); private trackedDuplicateNoteIds = new Map<number, number[]>();
@@ -453,11 +454,13 @@ export class AnkiIntegration {
mergeFieldValue: (existing, newValue, overwrite) => mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite), this.mergeFieldValue(existing, newValue, overwrite),
generateAudioFilename: () => this.generateAudioFilename(), generateAudioFilename: () => this.generateAudioFilename(),
generateAudio: () => this.generateAudio(), generateAudio: (context) => this.generateAudio(context),
generateImageFilename: () => this.generateImageFilename(), generateImageFilename: () => this.generateImageFilename(),
generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds), generateImage: (animatedLeadInSeconds, context) =>
this.generateImage(animatedLeadInSeconds, context),
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label), showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message), showOsdNotification: (message) => this.showOsdNotification(message),
@@ -474,6 +477,7 @@ export class AnkiIntegration {
client: { client: {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields), updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds), deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
}, },
getConfig: () => this.config, getConfig: () => this.config,
@@ -673,7 +677,55 @@ export class AnkiIntegration {
return `${prefix}<b>${highlightedText}</b>${suffix}`; return `${prefix}<b>${highlightedText}</b>${suffix}`;
} }
private async generateAudio(): Promise<Buffer | null> { private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
if (!this.consumeSubtitleMiningContextCallback) {
return null;
}
try {
return this.consumeSubtitleMiningContextCallback();
} catch (error) {
log.warn('Subtitle mining context callback failed:', (error as Error).message);
return null;
}
}
private getSubtitleMediaRange(context?: SubtitleMiningContext): {
startTime: number;
endTime: number;
} {
if (
context &&
Number.isFinite(context.startTime) &&
Number.isFinite(context.endTime) &&
context.endTime > context.startTime
) {
return {
startTime: context.startTime,
endTime: context.endTime,
};
}
if (
Number.isFinite(this.mpvClient.currentSubStart) &&
Number.isFinite(this.mpvClient.currentSubEnd) &&
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
) {
return {
startTime: this.mpvClient.currentSubStart,
endTime: this.mpvClient.currentSubEnd,
};
}
const currentTime = this.mpvClient.currentTimePos || 0;
const fallback = this.getFallbackDurationSeconds() / 2;
return {
startTime: currentTime - fallback,
endTime: currentTime + fallback,
};
}
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
const mpvClient = this.mpvClient; const mpvClient = this.mpvClient;
if (!mpvClient || !mpvClient.currentVideoPath) { if (!mpvClient || !mpvClient.currentVideoPath) {
return null; return null;
@@ -683,15 +735,7 @@ export class AnkiIntegration {
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
let startTime = mpvClient.currentSubStart; const { startTime, endTime } = this.getSubtitleMediaRange(context);
let endTime = mpvClient.currentSubEnd;
if (startTime === undefined || endTime === undefined) {
const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback;
endTime = currentTime + fallback;
}
return this.mediaGenerator.generateAudio( return this.mediaGenerator.generateAudio(
videoPath, videoPath,
@@ -702,7 +746,10 @@ export class AnkiIntegration {
); );
} }
private async generateImage(animatedLeadInSeconds = 0): Promise<Buffer | null> { private async generateImage(
animatedLeadInSeconds = 0,
context?: SubtitleMiningContext,
): Promise<Buffer | null> {
if (!this.mpvClient || !this.mpvClient.currentVideoPath) { if (!this.mpvClient || !this.mpvClient.currentVideoPath) {
return null; return null;
} }
@@ -711,22 +758,16 @@ export class AnkiIntegration {
if (!videoPath) { if (!videoPath) {
return null; return null;
} }
const timestamp = this.mpvClient.currentTimePos || 0; const mediaRange = this.getSubtitleMediaRange(context);
const timestamp = context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0;
if (this.config.media?.imageType === 'avif') { if (this.config.media?.imageType === 'avif') {
let startTime = this.mpvClient.currentSubStart;
let endTime = this.mpvClient.currentSubEnd;
if (startTime === undefined || endTime === undefined) {
const fallback = this.getFallbackDurationSeconds() / 2;
startTime = timestamp - fallback;
endTime = timestamp + fallback;
}
return this.mediaGenerator.generateAnimatedImage( return this.mediaGenerator.generateAnimatedImage(
videoPath, videoPath,
startTime, mediaRange.startTime,
endTime, mediaRange.endTime,
this.config.media?.audioPadding, this.config.media?.audioPadding,
{ {
fps: this.config.media?.animatedFps, fps: this.config.media?.animatedFps,
@@ -1064,18 +1105,48 @@ export class AnkiIntegration {
endTime: number, endTime: number,
secondarySubText?: string, secondarySubText?: string,
): Promise<boolean> { ): Promise<boolean> {
return this.cardCreationService.createSentenceCard( const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
const created = await this.cardCreationService.createSentenceCard(
sentence, sentence,
startTime, startTime,
endTime, endTime,
secondarySubText, secondarySubText,
); );
if (
created &&
this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
trackedDuplicateNoteIdsBeforeCreate,
)
) {
try {
await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
} catch (error) {
log.warn('Failed to trigger field grouping after sentence card creation:', error);
}
}
return created;
} }
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void { trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]); this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
} }
private shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
): boolean {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
return false;
}
for (const noteId of this.trackedDuplicateNoteIds.keys()) {
if (!trackedDuplicateNoteIdsBeforeCreate.has(noteId)) {
return true;
}
}
return false;
}
private async findDuplicateNote( private async findDuplicateNote(
expression: string, expression: string,
excludeNoteId: number, excludeNoteId: number,
@@ -1287,6 +1358,10 @@ export class AnkiIntegration {
this.knownWordCacheUpdatedCallback = callback; this.knownWordCacheUpdatedCallback = callback;
} }
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
this.consumeSubtitleMiningContextCallback = callback;
}
resolveCurrentNoteId(noteId: number): number { resolveCurrentNoteId(noteId: number): number {
let resolved = noteId; let resolved = noteId;
const seen = new Set<number>(); const seen = new Set<number>();
@@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record<string, string>): FieldGrouping
}; };
} }
test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => { test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => {
const { collaborator } = createCollaborator({ const { collaborator } = createCollaborator({
config: { config: {
fields: { fields: {
image: 'Illustration', image: 'Illustration',
sentence: 'SentenceText', sentence: 'SentenceText',
audio: 'ExpressionAudio', audio: 'CustomWordAudio',
miscInfo: 'ExtraInfo', miscInfo: 'ExtraInfo',
}, },
}, },
@@ -97,33 +97,84 @@ test('getGroupableFieldNames includes configured fields without duplicating Expr
]); ]);
}); });
test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => { test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => {
const { collaborator } = createCollaborator({ const { collaborator } = createCollaborator();
config: {
fields: {
audio: 'CustomAudio',
},
},
});
const merged = await collaborator.computeFieldGroupingMergedFields( const merged = await collaborator.computeFieldGroupingMergedFields(
1, 300,
2, 200,
makeNote(1, { makeNote(300, {
SentenceAudio: '[sound:keep.mp3]', Sentence: 'original sentence',
CustomAudio: '[sound:stale.mp3]', SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]',
Picture: '<img src="original.png">',
MiscInfo: 'original misc',
ExpressionAudio: '[sound:word.mp3]',
}), }),
makeNote(2, { makeNote(200, {
Sentence: 'new sentence',
SentenceAudio: '[sound:new.mp3]', SentenceAudio: '[sound:new.mp3]',
Picture: '<img src="new.png">',
MiscInfo: 'new misc',
}), }),
false, false,
); );
assert.equal( assert.equal(
merged.SentenceAudio, merged.Sentence,
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>', '<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="300">[sound:original-a.mp3] [sound:original-b.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
);
assert.equal(
merged.MiscInfo,
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
);
assert.equal('ExpressionAudio' in merged, false);
});
test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
200,
300,
makeNote(200, {
Sentence: 'new sentence',
SentenceAudio: '[sound:new.mp3]',
Picture: '<img src="new.png">',
MiscInfo: 'new misc',
}),
makeNote(300, {
Sentence: 'original sentence',
SentenceAudio: '[sound:original.mp3]',
Picture: '<img src="original.png">',
MiscInfo: 'original misc',
}),
false,
);
assert.equal(
merged.Sentence,
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="300">[sound:original.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
);
assert.equal(
merged.Picture,
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
);
assert.equal(
merged.MiscInfo,
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
); );
assert.equal(merged.CustomAudio, merged.SentenceAudio);
}); });
test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => { test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => {
@@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty
assert.equal( assert.equal(
merged.Sentence, merged.Sentence,
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>', '<span data-group-id="4">source sentence</span><span data-group-id="3"><span data-group-id="abc">keep sentence</span></span>',
); );
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>'); assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
assert.equal(warnings.length, 4); assert.equal(warnings.length, 4);
@@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">'); assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>'); assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
}); });
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: 'original furigana',
}),
makeNote(200, {
SentenceFurigana: '',
}),
false,
);
assert.equal(merged.SentenceFurigana, '');
});
+44 -113
View File
@@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator {
fields.push('Picture'); fields.push('Picture');
if (config.fields?.image) fields.push(config.fields?.image); if (config.fields?.image) fields.push(config.fields?.image);
if (config.fields?.sentence) fields.push(config.fields?.sentence); if (config.fields?.sentence) fields.push(config.fields?.sentence);
if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') {
fields.push(config.fields?.audio);
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceAudioField = sentenceCardConfig.audioField; const sentenceAudioField = sentenceCardConfig.audioField;
if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField);
@@ -94,12 +91,6 @@ export class FieldGroupingMergeCollaborator {
} }
} }
if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) {
sourceFields['SentenceFurigana'] = sourceFields['Sentence'];
}
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
}
if (!sourceFields[configuredWordField] && sourceFields['Expression']) { if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
sourceFields[configuredWordField] = sourceFields['Expression']; sourceFields[configuredWordField] = sourceFields['Expression'];
} }
@@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator {
if (!sourceFields['Word'] && sourceFields[configuredWordField]) { if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
sourceFields['Word'] = sourceFields[configuredWordField]; sourceFields['Word'] = sourceFields[configuredWordField];
} }
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
}
if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) {
sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio'];
}
if ( if (
config.fields?.sentence && config.fields?.sentence &&
!sourceFields[config.fields?.sentence] && !sourceFields[config.fields?.sentence] &&
@@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator {
const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName);
if (!existingValue.trim() && !newValue.trim()) continue; if (!existingValue.trim() && !newValue.trim()) continue;
if (keepFieldNormalized === 'sentencefurigana') {
mergedFields[keepFieldName] =
existingValue.trim() && newValue.trim()
? this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
)
: '';
continue;
}
if (isStrictField) { if (isStrictField) {
mergedFields[keepFieldName] = this.applyFieldGrouping( mergedFields[keepFieldName] = this.applyFieldGrouping(
existingValue, existingValue,
@@ -191,29 +189,6 @@ export class FieldGroupingMergeCollaborator {
} }
} }
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const resolvedSentenceAudioField = this.deps.resolveFieldName(
keepFieldNames,
sentenceCardConfig.audioField || 'SentenceAudio',
);
const resolvedExpressionAudioField = this.deps.resolveFieldName(
keepFieldNames,
config.fields?.audio || 'ExpressionAudio',
);
if (
resolvedSentenceAudioField &&
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
) {
const mergedSentenceAudioValue =
mergedFields[resolvedSentenceAudioField] ||
keepNoteInfo.fields[resolvedSentenceAudioField]?.value ||
'';
if (mergedSentenceAudioValue.trim()) {
mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue;
}
}
return mergedFields; return mergedFields;
} }
@@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator {
} }
private extractUngroupedValue(value: string): string { private extractUngroupedValue(value: string): string {
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\s\S]*?<\/span>/gi; const ungrouped = this.extractUngroupedRemainder(value);
const ungrouped = value.replace(groupedSpanRegex, '').trim();
if (ungrouped) return ungrouped; if (ungrouped) return ungrouped;
return value.trim(); return value.trim();
} }
private extractLastSoundTag(value: string): string { private extractUngroupedRemainder(value: string): string {
const matches = value.match(/\[sound:[^\]]+\]/g); const groupedSpanRegex = /<span\b[^>]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
if (!matches || matches.length === 0) return ''; return value.replace(groupedSpanRegex, '').trim();
return matches[matches.length - 1]!;
}
private extractLastImageTag(value: string): string {
const matches = value.match(/<img\b[^>]*>/gi);
if (!matches || matches.length === 0) return '';
return matches[matches.length - 1]!;
} }
private extractImageTags(value: string): string[] { private extractImageTags(value: string): string[] {
@@ -274,7 +241,7 @@ export class FieldGroupingMergeCollaborator {
} }
} }
const spanRegex = /<span\s+data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi; const spanRegex = /<span\b[^>]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
let match; let match;
while ((match = spanRegex.exec(value)) !== null) { while ((match = spanRegex.exec(value)) !== null) {
const groupId = Number(match[1]); const groupId = Number(match[1]);
@@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator {
fieldName: string, fieldName: string,
): { groupId: number; content: string }[] { ): { groupId: number; content: string }[] {
const entries = this.extractSpanEntries(value, fieldName); const entries = this.extractSpanEntries(value, fieldName);
if (entries.length === 0) { const ungroupedSource =
const ungrouped = this.normalizeStrictGroupedValue( entries.length > 0
this.extractUngroupedValue(value), ? this.extractUngroupedRemainder(value)
fieldName, : this.extractUngroupedValue(value);
); const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName);
if (ungrouped) { if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped }); entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
} }
const unique: { groupId: number; content: string }[] = []; return entries;
const seen = new Set<string>();
for (const entry of entries) {
const key = entry.content;
if (seen.has(key)) continue;
seen.add(key);
unique.push(entry);
}
return unique;
} }
private parsePictureEntries( private parsePictureEntries(
@@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator {
if (!ungrouped) return ''; if (!ungrouped) return '';
const normalizedField = fieldName.toLowerCase(); const normalizedField = fieldName.toLowerCase();
if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) {
const lastSoundTag = this.extractLastSoundTag(ungrouped); this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
if (!lastSoundTag) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return lastSoundTag || ungrouped;
}
if (normalizedField === 'picture') {
const lastImageTag = this.extractLastImageTag(ungrouped);
if (!lastImageTag) {
this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag');
}
return lastImageTag || ungrouped;
} }
return ungrouped; return ungrouped;
} }
private getPictureDedupKey(tag: string): string {
return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim();
}
private getStrictSpanGroupingFields(): Set<string> { private getStrictSpanGroupingFields(): Set<string> {
const strictFields = new Set(this.strictGroupingFieldDefaults); const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
@@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator {
return this.getStrictSpanGroupingFields().has(normalized); return this.getStrictSpanGroupingFields().has(normalized);
} }
private isPictureField(fieldName: string): boolean {
const normalized = fieldName.toLowerCase();
const configuredImageField = this.deps.getConfig().fields?.image?.toLowerCase();
return normalized === 'picture' || normalized === configuredImageField;
}
private sortEntriesByGroupIdDescending<T extends { groupId: number }>(entries: T[]): T[] {
return [...entries].sort((a, b) => b.groupId - a.groupId);
}
private applyFieldGrouping( private applyFieldGrouping(
existingValue: string, existingValue: string,
newValue: string, newValue: string,
@@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator {
fieldName: string, fieldName: string,
): string { ): string {
if (this.shouldUseStrictSpanGrouping(fieldName)) { if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (fieldName.toLowerCase() === 'picture') { if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId);
if (keepEntries.length === 0 && sourceEntries.length === 0) { if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue; return existingValue || newValue;
} }
const mergedTags = keepEntries.map((entry) => return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries])
this.ensureImageGroupId(entry.tag, entry.groupId), .map((entry) => this.ensureImageGroupId(entry.tag, entry.groupId))
); .join('');
const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag)));
for (const entry of sourceEntries) {
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
const dedupKey = this.getPictureDedupKey(normalized);
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
mergedTags.push(normalized);
}
return mergedTags.join('');
} }
const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName);
@@ -423,19 +366,7 @@ export class FieldGroupingMergeCollaborator {
if (keepEntries.length === 0 && sourceEntries.length === 0) { if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue; return existingValue || newValue;
} }
if (sourceEntries.length === 0) { const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]);
return keepEntries
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
.join('');
}
const merged = [...keepEntries];
const seen = new Set(keepEntries.map((entry) => entry.content));
for (const entry of sourceEntries) {
const key = entry.content;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
}
if (merged.length === 0) return existingValue; if (merged.length === 0) return existingValue;
return merged return merged
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`) .map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
@@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an
type NoteInfo = { type NoteInfo = {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
tags?: string[];
}; };
type ManualChoice = { type ManualChoice = {
@@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: {
function createWorkflowHarness() { function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = []; const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = []; const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = []; const statuses: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = []; const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{ const mergeCalls: Array<{
@@ -49,6 +51,9 @@ function createWorkflowHarness() {
updateNoteFields: async (noteId: number, fields: Record<string, string>) => { updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields }); updates.push({ noteId, fields });
}, },
addTags: async (noteIds: number[], tags: string[]) => {
addedTags.push({ noteIds, tags });
},
deleteNotes: async (noteIds: number[]) => { deleteNotes: async (noteIds: number[]) => {
deleted.push(noteIds); deleted.push(noteIds);
}, },
@@ -117,6 +122,7 @@ function createWorkflowHarness() {
workflow: new FieldGroupingWorkflow(deps), workflow: new FieldGroupingWorkflow(deps),
updates, updates,
deleted, deleted,
addedTags,
rememberedMerges, rememberedMerges,
statuses, statuses,
mergeCalls, mergeCalls,
@@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
assert.equal(harness.statuses.length, 1); assert.equal(harness.statuses.length, 1);
}); });
test('FieldGroupingWorkflow merges source tags into target and filters special source tags', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async (noteIds: number[]) =>
noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: `word-${noteId}` },
Sentence: { value: `line-${noteId}` },
},
tags:
noteId === 1 ? ['kinkoi', 'marked'] : ['SubMiner', 'marked', 'leech', 'potential_leech'],
}));
await harness.workflow.handleAuto(1, 2, {
noteId: 2,
fields: {
Expression: { value: 'word-2' },
Sentence: { value: 'line-2' },
},
tags: ['SubMiner', 'marked', 'leech', 'potential_leech'],
});
assert.deepEqual(harness.addedTags, [{ noteIds: [1], tags: ['SubMiner'] }]);
});
test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => { test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
@@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo { export interface FieldGroupingWorkflowNoteInfo {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
tags?: string[];
} }
export interface FieldGroupingWorkflowDeps { export interface FieldGroupingWorkflowDeps {
client: { client: {
notesInfo(noteIds: number[]): Promise<unknown>; notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>; updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
addTags(noteIds: number[], tags: string[]): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>; deleteNotes(noteIds: number[]): Promise<void>;
}; };
getConfig: () => { getConfig: () => {
@@ -156,6 +158,11 @@ export class FieldGroupingWorkflow {
await this.deps.addConfiguredTagsToNote(keepNoteId); await this.deps.addConfiguredTagsToNote(keepNoteId);
} }
const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo);
if (tagsToAdd.length > 0) {
await this.deps.client.addTags([keepNoteId], tagsToAdd);
}
if (deleteDuplicate) { if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]); await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId); this.deps.removeTrackedNoteId(deleteNoteId);
@@ -200,6 +207,24 @@ export class FieldGroupingWorkflow {
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()); return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
} }
private getMergeTagsToAdd(
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
): string[] {
const targetTags = new Set((keepNoteInfo.tags ?? []).map((tag) => tag.trim()).filter(Boolean));
const unwantedSourceTags = new Set(['leech', 'marked', 'potential_leech']);
const tagsToAdd: string[] = [];
for (const rawTag of deleteNoteInfo.tags ?? []) {
const tag = rawTag.trim();
if (!tag || targetTags.has(tag) || unwantedSourceTags.has(tag)) continue;
targetTags.add(tag);
tagsToAdd.push(tag);
}
return tagsToAdd;
}
private async resolveFieldGroupingCallback(): Promise< private async resolveFieldGroupingCallback(): Promise<
| ((data: { | ((data: {
original: KikuDuplicateCardInfo; original: KikuDuplicateCardInfo;
@@ -5,6 +5,7 @@ import {
type NoteUpdateWorkflowDeps, type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo, type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow'; } from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle';
function createWorkflowHarness() { function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = []; const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
@@ -203,3 +204,72 @@ test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word
assert.equal(receivedLeadInSeconds, 1.25); assert.equal(receivedLeadInSeconds, 1.25);
}); });
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
const harness = createWorkflowHarness();
const sidebarContext = {
source: 'subtitle-sidebar' as const,
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
};
let audioContext: unknown = null;
let imageContext: unknown = null;
let miscInfoStartTime: number | undefined;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'sidebar previous line' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'avif',
},
behavior: {},
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
};
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
imageContext = context ?? null;
return Buffer.from('image');
};
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
miscInfoStartTime = startTimeSeconds;
return `start:${startTimeSeconds}`;
};
(
harness.deps as NoteUpdateWorkflowDeps & {
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
}
).consumeSubtitleMiningContext = () => sidebarContext;
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
assert.deepEqual(audioContext, sidebarContext);
assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10);
});
+72 -6
View File
@@ -1,5 +1,6 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
export interface NoteUpdateWorkflowNoteInfo { export interface NoteUpdateWorkflowNoteInfo {
noteId: number; noteId: number;
@@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps {
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>; getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string; generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>; generateAudio: (context?: SubtitleMiningContext) => Promise<Buffer | null>;
generateImageFilename: () => string; generateImageFilename: () => string;
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>; generateImage: (
animatedLeadInSeconds?: number,
context?: SubtitleMiningContext,
) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
addConfiguredTagsToNote: (noteId: number) => Promise<void>; addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>; showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void; showOsdNotification: (message: string) => void;
@@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps {
logError: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void;
} }
function normalizeSubtitleContextText(text: string): string {
return text
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function hasUsableSubtitleContextTiming(context: SubtitleMiningContext): boolean {
return (
Number.isFinite(context.startTime) &&
Number.isFinite(context.endTime) &&
context.endTime > context.startTime
);
}
function subtitleContextMatchesSentence(contextText: string, noteSentence: string): boolean {
const normalizedContext = normalizeSubtitleContextText(contextText);
const normalizedSentence = normalizeSubtitleContextText(noteSentence);
if (!normalizedContext || !normalizedSentence) {
return false;
}
return (
normalizedContext === normalizedSentence ||
normalizedContext.includes(normalizedSentence) ||
normalizedSentence.includes(normalizedContext)
);
}
export class NoteUpdateWorkflow { export class NoteUpdateWorkflow {
constructor(private readonly deps: NoteUpdateWorkflowDeps) {} constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
private consumeMatchingSubtitleMiningContext(
fields: Record<string, string>,
sentenceField: string,
configuredSentenceField?: string,
): SubtitleMiningContext | null {
const context = this.deps.consumeSubtitleMiningContext?.() ?? null;
if (!context || !hasUsableSubtitleContextTiming(context)) {
return null;
}
const candidateFields = [
sentenceField,
configuredSentenceField,
DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
];
const noteSentence = candidateFields
.map((fieldName) => (fieldName ? fields[fieldName.toLowerCase()] : undefined))
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (!noteSentence || subtitleContextMatchesSentence(context.text, noteSentence)) {
return context;
}
return null;
}
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> { async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card'); this.deps.beginUpdateProgress('Updating card');
try { try {
@@ -121,8 +179,13 @@ export class NoteUpdateWorkflow {
let updatePerformed = false; let updatePerformed = false;
let miscInfoFilename: string | null = null; let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField; const sentenceField = sentenceCardConfig.sentenceField;
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
fields,
sentenceField,
config.fields?.sentence,
);
const currentSubtitleText = this.deps.getCurrentSubtitleText(); const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) { if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields); const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence; updatedFields[sentenceField] = processedSentence;
@@ -132,7 +195,7 @@ export class NoteUpdateWorkflow {
if (config.media?.generateAudio) { if (config.media?.generateAudio) {
try { try {
const audioFilename = this.deps.generateAudioFilename(); const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio(); const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
@@ -158,7 +221,10 @@ export class NoteUpdateWorkflow {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename(); const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds); const imageBuffer = await this.deps.generateImage(
animatedLeadInSeconds,
subtitleMiningContext ?? undefined,
);
if (imageBuffer) { if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -189,7 +255,7 @@ export class NoteUpdateWorkflow {
if (config.fields?.miscInfo) { if (config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern( const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '', miscInfoFilename || '',
this.deps.getCurrentSubtitleStart(), subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
); );
const miscInfoField = this.deps.resolveConfiguredFieldName( const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo, noteInfo,
+39
View File
@@ -104,6 +104,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true); assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true); assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true); assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
@@ -545,6 +546,44 @@ test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid val
); );
}); });
test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"primaryVisibleOnYomitanPopup": false
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"primaryVisibleOnYomitanPopup": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup,
DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'),
);
});
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
const validDir = makeTempDir(); const validDir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
preserveLineBreaks: false, preserveLineBreaks: false,
autoPauseVideoOnHover: true, autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true, autoPauseVideoOnYomitanPopup: true,
primaryVisibleOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6', hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent', hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false, nameMatchEnabled: false,
@@ -57,6 +57,13 @@ export function buildSubtitleConfigOptionRegistry(
description: description:
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.', 'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
}, },
{
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.primaryVisibleOnYomitanPopup,
description:
'Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode.',
},
{ {
path: 'subtitleStyle.hoverTokenColor', path: 'subtitleStyle.hoverTokenColor',
kind: 'string', kind: 'string',
+23
View File
@@ -186,6 +186,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover; const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup = const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup; resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor = const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor; resolved.subtitleStyle.hoverTokenBackgroundColor;
@@ -333,6 +335,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const primaryVisibleOnYomitanPopup = asBoolean(
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
.primaryVisibleOnYomitanPopup,
);
if (primaryVisibleOnYomitanPopup !== undefined) {
resolved.subtitleStyle.primaryVisibleOnYomitanPopup = primaryVisibleOnYomitanPopup;
} else if (
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
.primaryVisibleOnYomitanPopup !== undefined
) {
resolved.subtitleStyle.primaryVisibleOnYomitanPopup =
fallbackSubtitleStylePrimaryVisibleOnYomitanPopup;
warn(
'subtitleStyle.primaryVisibleOnYomitanPopup',
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
.primaryVisibleOnYomitanPopup,
resolved.subtitleStyle.primaryVisibleOnYomitanPopup,
'Expected boolean.',
);
}
const hoverTokenColor = asColor( const hoverTokenColor = asColor(
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor, (src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
); );
+27
View File
@@ -128,6 +128,33 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
); );
}); });
test('subtitleStyle primaryVisibleOnYomitanPopup falls back on invalid value', () => {
const valid = createResolveContext({
subtitleStyle: {
primaryVisibleOnYomitanPopup: false,
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, false);
const { context, warnings } = createResolveContext({
subtitleStyle: {
primaryVisibleOnYomitanPopup: 'invalid' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, true);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup' &&
warning.message === 'Expected boolean.',
),
);
});
test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => { test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({ const valid = createResolveContext({
subtitleStyle: { subtitleStyle: {
+10 -1
View File
@@ -15,6 +15,8 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('subtitleStyle.fontSize').category, 'appearance'); assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior'); assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior'); assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
assert.equal(field('secondarySub.defaultMode').category, 'behavior'); assert.equal(field('secondarySub.defaultMode').category, 'behavior');
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position'); assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode'); assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
@@ -28,7 +30,14 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('mpv.profile').section, 'mpv Playback'); assert.equal(field('mpv.profile').section, 'mpv Playback');
assert.ok( assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') < fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), fields.findIndex(
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
),
);
assert.ok(
fields.findIndex(
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
); );
}); });
+9 -1
View File
@@ -168,6 +168,7 @@ const PATH_ORDER = new Map<string, number>(
'subtitleStyle.hoverTokenBackgroundColor', 'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css', 'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode', 'subtitleStyle.primaryDefaultMode',
'subtitleStyle.primaryVisibleOnYomitanPopup',
'subtitleStyle.secondary.fontColor', 'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor', 'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css', 'subtitleStyle.secondary.css',
@@ -218,6 +219,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode', 'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations', 'subtitleStyle.css': 'CSS Declarations',
@@ -251,6 +253,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.', 'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css': 'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'subtitleStyle.primaryVisibleOnYomitanPopup':
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
'websocket.enabled': 'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs': 'discordPresence.updateIntervalMs':
@@ -359,7 +363,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subtitleStyle.secondary.')) { if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'appearance', section: 'Secondary Subtitle Appearance' }; return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
} }
if (path === 'subtitleStyle.primaryDefaultMode') { if (
path === 'subtitleStyle.primaryDefaultMode' ||
path === 'subtitleStyle.primaryVisibleOnYomitanPopup'
) {
return { category: 'behavior', section: 'Subtitle Behavior' }; return { category: 'behavior', section: 'Subtitle Behavior' };
} }
if (path.startsWith('subtitleStyle.')) { if (path.startsWith('subtitleStyle.')) {
@@ -603,6 +610,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
} }
function fieldTypeRank(field: ConfigSettingsField): number { function fieldTypeRank(field: ConfigSettingsField): number {
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
if (field.control !== 'boolean') return 2; if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1; return isFeatureToggle(field) ? 0 : 1;
} }
@@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
assert.equal(visible, false); assert.equal(visible, false);
assert.deepEqual(visibilityTransitions, [true, false]); assert.deepEqual(visibilityTransitions, [true, false]);
}); });
async function settleWithinMicrotasks<T>(
promise: Promise<T>,
attempts = 10,
): Promise<T | 'timeout'> {
let settled = false;
let settledValue: T | undefined;
void promise.then((value) => {
settled = true;
settledValue = value;
});
for (let i = 0; i < attempts; i += 1) {
await Promise.resolve();
if (settled) {
return settledValue as T;
}
}
return 'timeout';
}
test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const sends: Array<{
channel: string;
payload: unknown;
restoreOnModalClose?: string;
preferModalWindow?: boolean;
}> = [];
const waitCalls: Array<{ modal: string; timeoutMs: number }> = [];
const warnings: string[] = [];
const closed: string[] = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
try {
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => true,
setVisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (nextResolver) => {
resolver = nextResolver;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
sendToVisibleOverlay: (channel, payload, runtimeOptions) => {
sends.push({
channel,
payload,
restoreOnModalClose: runtimeOptions?.restoreOnModalClose,
preferModalWindow: runtimeOptions?.preferModalWindow,
});
return true;
},
waitForModalOpen: async (modal, timeoutMs) => {
waitCalls.push({ modal, timeoutMs });
return false;
},
handleOverlayModalClosed: (modal) => {
closed.push(modal);
},
logWarn: (message) => {
warnings.push(message);
},
});
const request = {
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
};
const pendingChoice = runtime.createFieldGroupingCallback()(request);
const result = await settleWithinMicrotasks(pendingChoice);
assert.notEqual(result, 'timeout');
assert.deepEqual(result, {
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
assert.equal(resolver, null);
assert.deepEqual(
sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({
channel,
restoreOnModalClose,
preferModalWindow,
})),
[
{
channel: 'kiku:field-grouping-request',
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
{
channel: 'kiku:field-grouping-request',
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
],
);
assert.deepEqual(waitCalls, [
{ modal: 'kiku', timeoutMs: 1500 },
{ modal: 'kiku', timeoutMs: 1500 },
]);
assert.deepEqual(warnings, [
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
assert.deepEqual(closed, ['kiku']);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
+48 -3
View File
@@ -8,6 +8,10 @@ interface WindowLike {
}; };
} }
const KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS = 1500;
const KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING =
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.';
export interface FieldGroupingOverlayRuntimeOptions<T extends string> { export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
@@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>; getRestoreVisibleOverlayOnModalClose: () => Set<T>;
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed?: (modal: T) => void;
logWarn?: (message: string) => void;
sendToVisibleOverlay?: ( sendToVisibleOverlay?: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T }, runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean; ) => boolean;
} }
@@ -28,7 +35,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
sendToVisibleOverlay: ( sendToVisibleOverlay: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T }, runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean; ) => boolean;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
@@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
const sendToVisibleOverlay = ( const sendToVisibleOverlay = (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T }, runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
): boolean => { ): boolean => {
if (options.sendToVisibleOverlay) { if (options.sendToVisibleOverlay) {
const wasVisible = options.getVisibleOverlayVisible(); const wasVisible = options.getVisibleOverlayVisible();
@@ -58,6 +65,43 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
}); });
}; };
const sendKikuFieldGroupingRequest = async (
data: KikuFieldGroupingRequestData,
): Promise<boolean> => {
const kikuModal = 'kiku' as T;
const sendOpen = (): boolean =>
sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: kikuModal,
preferModalWindow: true,
});
if (!options.waitForModalOpen) {
return sendOpen();
}
if (!sendOpen()) {
return false;
}
if (await options.waitForModalOpen(kikuModal, KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS)) {
return true;
}
options.logWarn?.(KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING);
if (!sendOpen()) {
options.handleOverlayModalClosed?.(kikuModal);
return false;
}
const opened = await options.waitForModalOpen(
kikuModal,
KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS,
);
if (!opened) {
options.handleOverlayModalClosed?.(kikuModal);
}
return opened;
};
const createFieldGroupingCallback = (): (( const createFieldGroupingCallback = (): ((
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => { ) => Promise<KikuFieldGroupingChoice>) => {
@@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
getResolver: options.getResolver, getResolver: options.getResolver,
setResolver: options.setResolver, setResolver: options.setResolver,
sendToVisibleOverlay, sendToVisibleOverlay,
sendKikuFieldGroupingRequest,
}); });
}; };
+32 -14
View File
@@ -5,7 +5,7 @@ export function createFieldGroupingCallback(options: {
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> { }): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => { return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: {
const previousVisibleOverlay = options.getVisibleOverlayVisible(); const previousVisibleOverlay = options.getVisibleOverlayVisible();
let settled = false; let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (choice: KikuFieldGroupingChoice): void => { const finish = (choice: KikuFieldGroupingChoice): void => {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
if (options.getResolver() === finish) { if (options.getResolver() === finish) {
options.setResolver(null); options.setResolver(null);
} }
@@ -36,25 +41,38 @@ export function createFieldGroupingCallback(options: {
}; };
options.setResolver(finish); options.setResolver(finish);
if (!options.sendRequestToVisibleOverlay(data)) { void Promise.resolve(options.sendRequestToVisibleOverlay(data)).then(
finish({ (sent) => {
keepNoteId: 0, if (settled) return;
deleteNoteId: 0, if (!sent) {
deleteDuplicate: true, finish({
cancelled: true, keepNoteId: 0,
}); deleteNoteId: 0,
return; deleteDuplicate: true,
} cancelled: true,
setTimeout(() => { });
if (!settled) { return;
}
timeout = setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
},
() => {
finish({ finish({
keepNoteId: 0, keepNoteId: 0,
deleteNoteId: 0, deleteNoteId: 0,
deleteDuplicate: true, deleteDuplicate: true,
cancelled: true, cancelled: true,
}); });
} },
}, 90000); );
}); });
}; };
} }
+37
View File
@@ -630,6 +630,43 @@ test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion
assert.deepEqual(calls, ['lookup']); assert.deepEqual(calls, ['lookup']);
}); });
test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const contexts: unknown[] = [];
const deps = createRegisterIpcDeps() as IpcServiceDeps & {
recordSubtitleMiningContext: (context: unknown | null) => void;
};
deps.recordSubtitleMiningContext = (context) => {
contexts.push(context);
};
registerIpcHandlers(deps, registrar);
const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup);
assert.equal(typeof handler, 'function');
handler?.(
{},
{
source: 'subtitle-sidebar',
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
},
);
assert.deepEqual(contexts, [
{
source: 'subtitle-sidebar',
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
},
]);
});
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(createRegisterIpcDeps(), registrar); registerIpcHandlers(createRegisterIpcDeps(), registrar);
+43 -1
View File
@@ -9,6 +9,7 @@ import type {
ResolvedControllerConfig, ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
RuntimeOptionValue, RuntimeOptionValue,
SubtitleMiningContext,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
@@ -95,6 +96,7 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>; runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>; getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: ( setCharacterDictionarySelection?: (
mediaId: number, mediaId: number,
@@ -175,6 +177,43 @@ interface IpcMainRegistrar {
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
} }
function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const record = payload as Record<string, unknown>;
const source = record.source;
const text = record.text;
const startTime = record.startTime;
const endTime = record.endTime;
const capturedAtMs = record.capturedAtMs;
if (
source !== 'subtitle-sidebar' ||
typeof text !== 'string' ||
text.trim().length === 0 ||
typeof startTime !== 'number' ||
typeof endTime !== 'number' ||
!Number.isFinite(startTime) ||
!Number.isFinite(endTime) ||
endTime <= startTime
) {
return null;
}
const parsed: SubtitleMiningContext = {
source: 'subtitle-sidebar',
text,
startTime,
endTime,
};
if (typeof capturedAtMs === 'number' && Number.isFinite(capturedAtMs)) {
parsed.capturedAtMs = capturedAtMs;
}
return parsed;
}
export interface IpcDepsRuntimeOptions { export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null; getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean; getVisibleOverlayVisibility: () => boolean;
@@ -230,6 +269,7 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>; runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>; getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: ( setCharacterDictionarySelection?: (
mediaId: number, mediaId: number,
@@ -257,6 +297,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalOpened: options.onOverlayModalOpened, onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings, openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp, quitApp: options.quitApp,
toggleDevTools: () => { toggleDevTools: () => {
const mainWindow = options.getMainWindow(); const mainWindow = options.getMainWindow();
@@ -423,7 +464,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.openYomitanSettings(); deps.openYomitanSettings();
}); });
ipc.on(IPC_CHANNELS.command.recordYomitanLookup, () => { ipc.on(IPC_CHANNELS.command.recordYomitanLookup, (_event: unknown, payload: unknown) => {
deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload));
deps.immersionTracker?.recordYomitanLookup(); deps.immersionTracker?.recordYomitanLookup();
}); });
+7 -4
View File
@@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
sendToVisibleOverlay: ( sendToVisibleOverlay: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T }, runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean; ) => boolean;
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> { }): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({ return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible, getVisibleOverlayVisible: options.getVisibleOverlayVisible,
@@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getResolver: options.getResolver, getResolver: options.getResolver,
setResolver: options.setResolver, setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) => sendRequestToVisibleOverlay: (data) =>
options.sendToVisibleOverlay('kiku:field-grouping-request', data, { options.sendKikuFieldGroupingRequest
restoreOnModalClose: 'kiku' as T, ? options.sendKikuFieldGroupingRequest(data)
}), : options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: 'kiku' as T,
}),
}); });
} }
+60
View File
@@ -25,6 +25,7 @@ interface YomitanTokenInput {
surface: string; surface: string;
reading?: string; reading?: string;
headword?: string; headword?: string;
frequencyRank?: number;
isNameMatch?: boolean; isNameMatch?: boolean;
wordClasses?: string[]; wordClasses?: string[];
} }
@@ -57,6 +58,7 @@ function makeDepsFromYomitanTokens(
startPos, startPos,
endPos, endPos,
isNameMatch: token.isNameMatch ?? false, isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
wordClasses: token.wordClasses, wordClasses: token.wordClasses,
}; };
}); });
@@ -4279,6 +4281,64 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin
assert.equal(result.tokens?.[0]?.frequencyRank, 5468); assert.equal(result.tokens?.[0]?.frequencyRank, 5468);
}); });
test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds', async () => {
const result = await tokenizeSubtitle(
'目の前',
makeDepsFromYomitanTokens(
[{ surface: '目の前', reading: 'めのまえ', headword: '目の前', frequencyRank: 581 }],
{
getFrequencyDictionaryEnabled: () => true,
tokenizeWithMecab: async () => [
{
headword: '目',
surface: '目',
reading: 'メ',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '一般',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'の',
surface: 'の',
reading: '',
startPos: 1,
endPos: 2,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '連体化',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '前',
surface: '前',
reading: 'マエ',
startPos: 2,
endPos: 3,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '副詞可能',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.surface, '目の前');
assert.equal(result.tokens?.[0]?.pos1, '名詞|助詞');
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
});
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => { test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'第二走者', '第二走者',
@@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
if (parts.length === 0) { if (parts.length === 0) {
return false; return false;
} }
// Frequency highlighting should be conservative: if any merged component is excluded,
// skip highlighting the whole token to avoid noisy merged fragments. return parts.every((part) => exclusions.has(part));
return parts.some((part) => exclusions.has(part));
} }
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> { function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
@@ -227,6 +226,10 @@ function isFrequencyExcludedByPos(
return true; return true;
} }
if (isKanaOnlyMixedFunctionContentToken(token, pos1Exclusions)) {
return true;
}
const normalizedPos1 = normalizePos1Tag(token.pos1); const normalizedPos1 = normalizePos1Tag(token.pos1);
const hasPos1 = normalizedPos1.length > 0; const hasPos1 = normalizedPos1.length > 0;
const normalizedPos2 = normalizePos2Tag(token.pos2); const normalizedPos2 = normalizePos2Tag(token.pos2);
@@ -564,6 +567,35 @@ function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean {
return chars.length === 1 && isKanaChar(chars[0]!); return chars.length === 1 && isKanaChar(chars[0]!);
} }
function isKanaOnlyText(text: string | undefined): boolean {
if (typeof text !== 'string') {
return false;
}
const normalized = text.trim();
if (!normalized) {
return false;
}
return [...normalized].every(isKanaChar);
}
function isKanaOnlyMixedFunctionContentToken(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
): boolean {
if (!isKanaOnlyText(token.surface)) {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
return (
pos1Parts.length >= 2 &&
pos1Parts.some((part) => pos1Exclusions.has(part)) &&
pos1Parts.some((part) => !pos1Exclusions.has(part))
);
}
function isJlptEligibleToken(token: MergedToken): boolean { function isJlptEligibleToken(token: MergedToken): boolean {
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) { if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
return false; return false;
+19 -2
View File
@@ -113,6 +113,7 @@ import type {
SecondarySubMode, SecondarySubMode,
SubtitleCue, SubtitleCue,
SubtitleData, SubtitleData,
SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
UpdateChannel, UpdateChannel,
WindowGeometry, WindowGeometry,
@@ -730,8 +731,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
const texthookerService = new Texthooker(() => { const texthookerService = new Texthooker(() => {
const config = getResolvedConfig(); const config = getResolvedConfig();
const characterDictionaryEnabled = const characterDictionaryEnabled =
config.subtitleStyle.nameMatchEnabled && config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled();
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownWordColoringEnabled = getRuntimeBooleanOption( const knownWordColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled', 'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled, config.ankiConnect.knownWords.highlightEnabled,
@@ -908,6 +908,7 @@ const {
appState, appState,
appLifecycleApp, appLifecycleApp,
} = bootServices; } = bootServices;
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
notifyAnilistTokenStoreWarning = (message: string) => { notifyAnilistTokenStoreWarning = (message: string) => {
logger.warn(`[AniList] ${message}`); logger.warn(`[AniList] ${message}`);
@@ -2181,6 +2182,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
setResolver: (resolver) => setFieldGroupingResolver(resolver), setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => getRestoreVisibleOverlayOnModalClose: () =>
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(), })(),
@@ -4190,6 +4194,14 @@ const immersionTrackerStartupMainDeps: Parameters<
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(),
); );
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
pendingSubtitleMiningContext = context;
};
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
const context = pendingSubtitleMiningContext;
pendingSubtitleMiningContext = null;
return context;
};
const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
@@ -5153,6 +5165,7 @@ function initializeOverlayRuntime(): void {
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAfterKnownWordUpdate,
); );
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
@@ -5948,6 +5961,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}, },
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => { tokenizeCurrentSubtitle: async () => {
@@ -6198,6 +6212,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAfterKnownWordUpdate,
); );
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext,
);
}, },
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification, showDesktopNotification,
+2
View File
@@ -96,6 +96,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark']; runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
recordSubtitleMiningContext?: IpcDepsRuntimeOptions['recordSubtitleMiningContext'];
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot']; getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot'];
@@ -273,6 +274,7 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus, getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow, retryAnilistQueueNow: params.retryAnilistQueueNow,
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark, runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
recordSubtitleMiningContext: params.recordSubtitleMiningContext,
getCharacterDictionarySelection: params.getCharacterDictionarySelection, getCharacterDictionarySelection: params.getCharacterDictionarySelection,
setCharacterDictionarySelection: params.setCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection,
getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot, getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot,
+22
View File
@@ -804,6 +804,28 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => {
assert.equal(await pending, true); assert.equal(await pending, true);
}); });
test('waitForModalOpen resolves true when modal acknowledgement arrives before waiter registration', async () => {
const modalWindow = createMockWindow();
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => modalWindow as never,
createModalWindow: () => modalWindow as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
runtime.sendToActiveOverlayWindow(
'kiku:field-grouping-request',
{},
{
restoreOnModalClose: 'kiku',
},
);
runtime.notifyOverlayModalOpened('kiku');
assert.equal(await runtime.waitForModalOpen('kiku', 5), true);
});
test('waitForModalOpen resolves false on timeout', async () => { test('waitForModalOpen resolves false on timeout', async () => {
const runtime = createOverlayModalRuntimeService({ const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null, getMainWindow: () => null,
+7
View File
@@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService(
): OverlayModalRuntime { ): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>(); const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
const openedModals = new Set<OverlayHostedModal>();
let modalActive = false; let modalActive = false;
let mainWindowMousePassthroughForcedByModal = false; let mainWindowMousePassthroughForcedByModal = false;
let mainWindowHiddenByModal = false; let mainWindowHiddenByModal = false;
@@ -375,6 +376,7 @@ export function createOverlayModalRuntimeService(
}; };
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
openedModals.delete(modal);
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal); restoreVisibleOverlayOnModalClose.delete(modal);
const modalWindow = deps.getModalWindow(); const modalWindow = deps.getModalWindow();
@@ -392,6 +394,7 @@ export function createOverlayModalRuntimeService(
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
openedModals.add(modal);
const waiters = modalOpenWaiters.get(modal) ?? []; const waiters = modalOpenWaiters.get(modal) ?? [];
modalOpenWaiters.delete(modal); modalOpenWaiters.delete(modal);
for (const resolve of waiters) { for (const resolve of waiters) {
@@ -420,6 +423,10 @@ export function createOverlayModalRuntimeService(
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> => const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
await new Promise<boolean>((resolve) => { await new Promise<boolean>((resolve) => {
if (openedModals.has(modal)) {
resolve(true);
return;
}
const waiters = modalOpenWaiters.get(modal) ?? []; const waiters = modalOpenWaiters.get(modal) ?? [];
const finish = (opened: boolean): void => { const finish = (opened: boolean): void => {
clearTimeout(timeout); clearTimeout(timeout);
@@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps<TModal extends string> = Omit<
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal }, runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
) => boolean; ) => boolean;
}; };
@@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
sendToVisibleOverlay: ( sendToVisibleOverlay: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal }, runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions), ) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
}); });
} }
+27
View File
@@ -11,6 +11,7 @@ type LogCandidate = {
mtimeMs: number; mtimeMs: number;
mtimeDateKey: string; mtimeDateKey: string;
fileDateKey: string | null; fileDateKey: string | null;
fileWeekKey: string | null;
}; };
export type ExportLogsResult = { export type ExportLogsResult = {
@@ -38,10 +39,21 @@ function localDateKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
} }
function localWeekKey(date: Date): string {
const startOfYear = new Date(date.getFullYear(), 0, 1);
const dayOfYear =
Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)) + 1;
return `${date.getFullYear()}-W${pad(Math.max(1, Math.ceil(dayOfYear / 7)))}`;
}
function filenameDateKey(fileName: string): string | null { function filenameDateKey(fileName: string): string | null {
return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null; return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null;
} }
function filenameWeekKey(fileName: string): string | null {
return fileName.match(/\d{4}-W\d{2}/)?.[0] ?? null;
}
function fileKind(fileName: string): string { function fileKind(fileName: string): string {
const match = fileName.match(/^([A-Za-z0-9_-]+)-/); const match = fileName.match(/^([A-Za-z0-9_-]+)-/);
return match?.[1] ?? fileName; return match?.[1] ?? fileName;
@@ -84,6 +96,7 @@ function buildCandidate(logsDir: string, entry: string): LogCandidate | null {
mtimeMs: stats.mtimeMs, mtimeMs: stats.mtimeMs,
mtimeDateKey: localDateKey(stats.mtime), mtimeDateKey: localDateKey(stats.mtime),
fileDateKey: filenameDateKey(entry), fileDateKey: filenameDateKey(entry),
fileWeekKey: filenameWeekKey(entry),
}; };
} }
@@ -117,6 +130,14 @@ function candidateFreshnessMs(candidate: LogCandidate): number {
if (candidate.fileDateKey) { if (candidate.fileDateKey) {
return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`); return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`);
} }
if (candidate.fileWeekKey) {
const match = candidate.fileWeekKey.match(/^(\d{4})-W(\d{2})$/);
if (match) {
const year = Number(match[1]);
const week = Number(match[2]);
return Date.UTC(year, 0, week * 7, 23, 59, 59, 999);
}
}
return candidate.mtimeMs; return candidate.mtimeMs;
} }
@@ -130,6 +151,12 @@ function selectLogCandidates(
return { mode: 'current-day', selected: currentDated }; return { mode: 'current-day', selected: currentDated };
} }
const currentWeek = localWeekKey(now);
const currentWeekly = candidates.filter((candidate) => candidate.fileWeekKey === currentWeek);
if (currentWeekly.length > 0) {
return { mode: 'current-day', selected: currentWeekly };
}
const currentUndated = candidates.filter( const currentUndated = candidates.filter(
(candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today, (candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today,
); );
+3 -2
View File
@@ -55,6 +55,7 @@ import type {
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
SessionNumericSelectionStartPayload, SessionNumericSelectionStartPayload,
SubtitleMiningContext,
YoutubePickerOpenPayload, YoutubePickerOpenPayload,
YoutubePickerResolveRequest, YoutubePickerResolveRequest,
YoutubePickerResolveResult, YoutubePickerResolveResult,
@@ -262,8 +263,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
}, },
recordYomitanLookup: () => { recordYomitanLookup: (context?: SubtitleMiningContext | null) => {
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup); ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null);
}, },
getSubtitlePosition: (): Promise<SubtitlePosition | null> => getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
+83
View File
@@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
} }
}); });
test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => {
const ctx = createMouseTestContext();
(ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true;
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
const bodyClassList = createClassList();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
body: { classList: bodyClassList },
querySelectorAll: () => [],
querySelector: () => null,
visibilityState: 'visible',
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true);
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
+13
View File
@@ -5,6 +5,7 @@ import {
YOMITAN_POPUP_MOUSE_ENTER_EVENT, YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT, YOMITAN_POPUP_SHOWN_EVENT,
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
isYomitanPopupVisible, isYomitanPopupVisible,
isYomitanPopupIframe, isYomitanPopupIframe,
} from '../yomitan-popup.js'; } from '../yomitan-popup.js';
@@ -44,10 +45,21 @@ export function createMouseHandlers(
return typeof document !== 'undefined' && isYomitanPopupVisible(document); return typeof document !== 'undefined' && isYomitanPopupVisible(document);
} }
function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void {
if (typeof document === 'undefined') {
return;
}
document.body?.classList?.toggle(
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
popupVisible && ctx.state.primaryVisibleOnYomitanPopup,
);
}
function syncPopupVisibilityState(assumeVisible = false): boolean { function syncPopupVisibilityState(assumeVisible = false): boolean {
const popupVisible = assumeVisible || getPopupVisibilityFromDom(); const popupVisible = assumeVisible || getPopupVisibilityFromDom();
yomitanPopupVisible = popupVisible; yomitanPopupVisible = popupVisible;
ctx.state.yomitanPopupVisible = popupVisible; ctx.state.yomitanPopupVisible = popupVisible;
syncPrimaryVisibleOnYomitanPopupClass(popupVisible);
return popupVisible; return popupVisible;
} }
@@ -293,6 +305,7 @@ export function createMouseHandlers(
yomitanPopupVisible = false; yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false;
syncPrimaryVisibleOnYomitanPopupClass(false);
popupPauseRequestId += 1; popupPauseRequestId += 1;
maybeResumeYomitanPopupPause(); maybeResumeYomitanPopupPause();
maybeResumeHoverPause(); maybeResumeHoverPause();
@@ -113,6 +113,88 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0); assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
}); });
test('subtitle sidebar mining context resolves selected row cue timing', () => {
const globals = globalThis as typeof globalThis & {
Element?: unknown;
Node?: unknown;
window?: unknown;
};
const previousElement = globals.Element;
const previousNode = globals.Node;
const previousWindow = globals.window;
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
dataset: Record<string, string> = {};
closest(selector: string) {
return selector === '.subtitle-sidebar-item' ? this : null;
}
}
const row = new FakeElement();
row.dataset.index = '1';
const textNode = new FakeNode();
textNode.parentElement = row;
Object.defineProperty(globalThis, 'Node', { configurable: true, value: FakeNode });
Object.defineProperty(globalThis, 'Element', { configurable: true, value: FakeElement });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
getSelection: () => ({ anchorNode: textNode, focusNode: null }),
},
});
try {
const state = createRendererState();
state.subtitleSidebarModalOpen = true;
state.subtitleSidebarCues = [
{ startTime: 1, endTime: 2, text: 'current line' },
{ startTime: 3, endTime: 5, text: 'sidebar previous line' },
];
const modal = createSubtitleSidebarModal(
{
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: createClassList(),
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
style: { setProperty: () => {} },
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: createListStub(),
},
state,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
},
);
const context = modal.getSubtitleSidebarMiningContext();
assert.equal(context?.source, 'subtitle-sidebar');
assert.equal(context?.text, 'sidebar previous line');
assert.equal(context?.startTime, 3);
assert.equal(context?.endTime, 5);
assert.equal(typeof context?.capturedAtMs, 'number');
} finally {
Object.defineProperty(globalThis, 'Element', { configurable: true, value: previousElement });
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('applySidebarCssDeclarations clears declarations removed by config reload', () => { test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
const removed: string[] = []; const removed: string[] = [];
const style = { const style = {
+84 -1
View File
@@ -1,4 +1,9 @@
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types'; import type {
SubtitleCue,
SubtitleData,
SubtitleMiningContext,
SubtitleSidebarSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context'; import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import { import {
@@ -201,6 +206,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarFocusedWithin = false; let subtitleSidebarFocusedWithin = false;
let subtitleSidebarYomitanPopupVisible = false; let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false; let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
function restoreEmbeddedSidebarPassthrough(): void { function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx); syncOverlayMouseIgnoreState(ctx);
@@ -213,9 +219,75 @@ export function createSubtitleSidebarModal(
function clearSidebarInteractionState(): void { function clearSidebarInteractionState(): void {
subtitleSidebarHovered = false; subtitleSidebarHovered = false;
subtitleSidebarFocusedWithin = false; subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
syncSidebarInteractionState(); syncSidebarInteractionState();
} }
function findCueIndexFromNode(node: Node | null): number | null {
if (!node || typeof Element === 'undefined') {
return null;
}
const element = node instanceof Element ? node : node.parentElement;
const row = element?.closest<HTMLElement>('.subtitle-sidebar-item') ?? null;
if (!row) {
return null;
}
const index = Number.parseInt(row.dataset.index ?? '', 10);
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
return null;
}
return index;
}
function rememberLookupCueFromTarget(target: EventTarget | null): void {
if (typeof Node === 'undefined') {
return;
}
if (!(target instanceof Node)) {
return;
}
const index = findCueIndexFromNode(target);
if (index === null) {
return;
}
lastSubtitleSidebarLookupCueIndex = index;
}
function getSubtitleSidebarMiningContext(): SubtitleMiningContext | null {
if (!ctx.state.subtitleSidebarModalOpen) {
return null;
}
const selection = window.getSelection?.() ?? null;
const selectionIndex =
findCueIndexFromNode(selection?.anchorNode ?? null) ??
findCueIndexFromNode(selection?.focusNode ?? null);
const index =
selectionIndex ??
(lastSubtitleSidebarLookupCueIndex >= 0 ? lastSubtitleSidebarLookupCueIndex : null);
if (index === null) {
return null;
}
const cue = ctx.state.subtitleSidebarCues[index];
if (
!cue ||
!Number.isFinite(cue.startTime) ||
!Number.isFinite(cue.endTime) ||
cue.endTime <= cue.startTime
) {
return null;
}
return {
source: 'subtitle-sidebar',
text: cue.text,
startTime: cue.startTime,
endTime: cue.endTime,
capturedAtMs: Date.now(),
};
}
function setStatus(message: string): void { function setStatus(message: string): void {
ctx.dom.subtitleSidebarStatus.textContent = message; ctx.dom.subtitleSidebarStatus.textContent = message;
} }
@@ -653,6 +725,12 @@ export function createSubtitleSidebarModal(
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => { ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS; ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS;
}); });
ctx.dom.subtitleSidebarList.addEventListener('pointerover', (event) => {
rememberLookupCueFromTarget(event.target);
});
ctx.dom.subtitleSidebarList.addEventListener('focusin', (event) => {
rememberLookupCueFromTarget(event.target);
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => { ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true; subtitleSidebarHovered = true;
syncSidebarInteractionState(); syncSidebarInteractionState();
@@ -677,6 +755,9 @@ export function createSubtitleSidebarModal(
}); });
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => { ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
subtitleSidebarHovered = false; subtitleSidebarHovered = false;
if (!subtitleSidebarFocusedWithin) {
lastSubtitleSidebarLookupCueIndex = -1;
}
syncSidebarInteractionState(); syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) { if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough(); restoreEmbeddedSidebarPassthrough();
@@ -700,6 +781,7 @@ export function createSubtitleSidebarModal(
} }
subtitleSidebarFocusedWithin = false; subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
syncSidebarInteractionState(); syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) { if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough(); restoreEmbeddedSidebarPassthrough();
@@ -736,5 +818,6 @@ export function createSubtitleSidebarModal(
}, },
handleSubtitleUpdated, handleSubtitleUpdated,
seekToCue, seekToCue,
getSubtitleSidebarMiningContext,
}; };
} }
+1 -1
View File
@@ -580,7 +580,7 @@ registerModalOpenHandlers();
registerKeyboardCommandHandlers(); registerKeyboardCommandHandlers();
registerYomitanLookupListener(window, () => { registerYomitanLookupListener(window, () => {
runGuarded('yomitan:lookup', () => { runGuarded('yomitan:lookup', () => {
window.electronAPI.recordYomitanLookup(); window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
}); });
}); });
+2
View File
@@ -114,6 +114,7 @@ export type RendererState = {
preserveSubtitleLineBreaks: boolean; preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean; autoPauseVideoOnSubtitleHover: boolean;
autoPauseVideoOnYomitanPopup: boolean; autoPauseVideoOnYomitanPopup: boolean;
primaryVisibleOnYomitanPopup: boolean;
frequencyDictionaryEnabled: boolean; frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number; frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded'; frequencyDictionaryMode: 'single' | 'banded';
@@ -225,6 +226,7 @@ export function createRendererState(): RendererState {
preserveSubtitleLineBreaks: false, preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false, autoPauseVideoOnSubtitleHover: false,
autoPauseVideoOnYomitanPopup: false, autoPauseVideoOnYomitanPopup: false,
primaryVisibleOnYomitanPopup: true,
frequencyDictionaryEnabled: false, frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000, frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single', frequencyDictionaryMode: 'single',
+4
View File
@@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
opacity: 1; opacity: 1;
} }
body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover {
opacity: 1;
}
#subtitleContainer.primary-sub-hidden { #subtitleContainer.primary-sub-hidden {
display: none; display: none;
pointer-events: none; pointer-events: none;
+6
View File
@@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
); );
assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/); assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/);
const primaryHoverYomitanPopupVisibleBlock = extractClassBlock(
cssText,
'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover',
);
assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/);
const secondaryEmbeddedHoverBlock = extractClassBlock( const secondaryEmbeddedHoverBlock = extractClassBlock(
cssText, cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover', 'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
+10
View File
@@ -6,6 +6,7 @@ import type {
SubtitleRendererStyleConfig, SubtitleRendererStyleConfig,
} from '../types'; } from '../types';
import type { RendererContext } from './context'; import type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
type FrequencyRenderSettings = { type FrequencyRenderSettings = {
enabled: boolean; enabled: boolean;
@@ -259,6 +260,13 @@ function applySubtitleCssDeclarations(
); );
} }
function syncPrimaryVisibleOnYomitanPopupClass(ctx: RendererContext): void {
document.body?.classList?.toggle(
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
ctx.state.yomitanPopupVisible && ctx.state.primaryVisibleOnYomitanPopup,
);
}
function pickInlineStyleDeclarations( function pickInlineStyleDeclarations(
declarations: Record<string, unknown>, declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>, includedKeys: ReadonlySet<string>,
@@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false; ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false; ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
syncPrimaryVisibleOnYomitanPopupClass(ctx);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
+1
View File
@@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave'; export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
export function registerYomitanLookupListener( export function registerYomitanLookupListener(
target: EventTarget = window, target: EventTarget = window,
+2 -1
View File
@@ -29,6 +29,7 @@ import type {
ResolvedSubtitleSidebarConfig, ResolvedSubtitleSidebarConfig,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
SubtitleSidebarSnapshot, SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig, SubtitleRendererStyleConfig,
@@ -413,7 +414,7 @@ export interface ElectronAPI {
onSubtitleAss: (callback: (assText: string) => void) => void; onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
recordYomitanLookup: () => void; recordYomitanLookup: (context?: SubtitleMiningContext | null) => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>; getSubtitlePosition: () => Promise<SubtitlePosition | null>;
saveSubtitlePosition: (position: SubtitlePosition) => void; saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => Promise<MecabStatus>; getMecabStatus: () => Promise<MecabStatus>;
+9
View File
@@ -81,6 +81,7 @@ export interface SubtitleStyleConfig {
preserveLineBreaks?: boolean; preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean; autoPauseVideoOnHover?: boolean;
autoPauseVideoOnYomitanPopup?: boolean; autoPauseVideoOnYomitanPopup?: boolean;
primaryVisibleOnYomitanPopup?: boolean;
hoverTokenColor?: string; hoverTokenColor?: string;
hoverTokenBackgroundColor?: string; hoverTokenBackgroundColor?: string;
nameMatchEnabled?: boolean; nameMatchEnabled?: boolean;
@@ -217,6 +218,14 @@ export interface SubtitleSidebarSnapshot {
config: SubtitleSidebarSnapshotConfig; config: SubtitleSidebarSnapshotConfig;
} }
export interface SubtitleMiningContext {
source: 'subtitle-sidebar';
text: string;
startTime: number;
endTime: number;
capturedAtMs?: number;
}
export interface SubtitleHoverTokenPayload { export interface SubtitleHoverTokenPayload {
tokenIndex: number | null; tokenIndex: number | null;
} }