mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
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:
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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> =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user