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

- Fix Kiku duplicate-card field grouping: local dupes trigger manual modal or auto-merge; fix modal-open ack race; fix merged field ordering, sentence-audio, furigana, and tag semantics
- Fix frequency annotations for single-token Yomitan compounds with internal particles (e.g. 目の前); keep pure grammar/kana spans unannotated
- Fix subtitle sidebar mining: use audio/image from clicked sidebar line, not current primary line
- Add `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitle visible while Yomitan popup is open
- Normalize trailing commas in config.example.jsonc
This commit is contained in:
2026-05-27 00:12:21 -07:00
parent 5b44981688
commit eb04ea97b1
49 changed files with 1711 additions and 662 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
+1
View File
@@ -0,0 +1 @@
- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: config
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line.
+83 -148
View File
@@ -5,7 +5,6 @@
* Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.
*/
{
// ==========================================
// Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
@@ -19,7 +18,7 @@
// ==========================================
"texthooker": {
"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.
// ==========================================
@@ -29,7 +28,7 @@
// ==========================================
"websocket": {
"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.
// ==========================================
@@ -39,7 +38,7 @@
// ==========================================
"annotationWebsocket": {
"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.
// ==========================================
@@ -54,8 +53,8 @@
"files": {
"app": true, // Write SubMiner app runtime 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
} // Files setting.
"mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
}, // Files setting.
}, // Controls logging verbosity.
// ==========================================
@@ -88,66 +87,66 @@
"leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 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.
"bindings": {
"toggleLookup": {
"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.
"closeLookup": {
"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.
"toggleKeyboardOnlyMode": {
"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.
"mineCard": {
"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.
"quitMpv": {
"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.
"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.
"nextAudio": {
"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.
"playCurrentAudio": {
"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.
"toggleMpvPause": {
"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.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
"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.
}, // 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.
// ==========================================
@@ -161,7 +160,7 @@
"mecab": true, // Warm up MeCab tokenizer 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
"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.
// ==========================================
@@ -173,7 +172,7 @@
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"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.
// ==========================================
@@ -199,7 +198,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"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.
"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.
// ==========================================
@@ -211,122 +210,76 @@
"keybindings": [
{
"key": "Space", // Key setting.
"command": [
"cycle",
"pause"
] // Command setting.
"command": ["cycle", "pause"], // Command setting.
},
{
"key": "KeyF", // Key setting.
"command": [
"cycle",
"fullscreen"
] // Command setting.
"command": ["cycle", "fullscreen"], // Command setting.
},
{
"key": "KeyJ", // Key setting.
"command": [
"cycle",
"sid"
] // Command setting.
"command": ["cycle", "sid"], // Command setting.
},
{
"key": "Shift+KeyJ", // Key setting.
"command": [
"cycle",
"secondary-sid"
] // Command setting.
"command": ["cycle", "secondary-sid"], // Command setting.
},
{
"key": "ArrowRight", // Key setting.
"command": [
"seek",
5
] // Command setting.
"command": ["seek", 5], // Command setting.
},
{
"key": "ArrowLeft", // Key setting.
"command": [
"seek",
-5
] // Command setting.
"command": ["seek", -5], // Command setting.
},
{
"key": "ArrowUp", // Key setting.
"command": [
"seek",
60
] // Command setting.
"command": ["seek", 60], // Command setting.
},
{
"key": "ArrowDown", // Key setting.
"command": [
"seek",
-60
] // Command setting.
"command": ["seek", -60], // Command setting.
},
{
"key": "Shift+KeyH", // Key setting.
"command": [
"sub-seek",
-1
] // Command setting.
"command": ["sub-seek", -1], // Command setting.
},
{
"key": "Shift+KeyL", // Key setting.
"command": [
"sub-seek",
1
] // Command setting.
"command": ["sub-seek", 1], // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"command": [
"__sub-delay-next-line"
] // Command setting.
"command": ["__sub-delay-next-line"], // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"command": [
"__sub-delay-prev-line"
] // Command setting.
"command": ["__sub-delay-prev-line"], // Command setting.
},
{
"key": "Ctrl+Alt+KeyC", // Key setting.
"command": [
"__youtube-picker-open"
] // Command setting.
"command": ["__youtube-picker-open"], // Command setting.
},
{
"key": "Ctrl+Alt+KeyP", // Key setting.
"command": [
"__playlist-browser-open"
] // Command setting.
"command": ["__playlist-browser-open"], // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
"__replay-subtitle"
] // Command setting.
"command": ["__replay-subtitle"], // Command setting.
},
{
"key": "Ctrl+Shift+KeyL", // Key setting.
"command": [
"__play-next-subtitle"
] // Command setting.
"command": ["__play-next-subtitle"], // Command setting.
},
{
"key": "KeyQ", // Key setting.
"command": [
"quit"
] // Command setting.
"command": ["quit"], // Command setting.
},
{
"key": "Ctrl+KeyW", // Key setting.
"command": [
"quit"
] // Command setting.
}
"command": ["quit"], // Command setting.
},
], // Default and custom keybindings that are merged with built-in defaults.
// ==========================================
@@ -338,7 +291,7 @@
"secondarySub": {
"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
"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.
// ==========================================
@@ -350,7 +303,7 @@
"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.
"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.
// ==========================================
@@ -358,7 +311,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"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.
// ==========================================
@@ -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.
"backdrop-filter": "blur(6px)", // Backdrop filter 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.
"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
"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
"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
"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.
@@ -399,7 +353,7 @@
"N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting.
"N4": "#8bd5ca", // N4 setting.
"N5": "#8aadf4" // N5 setting.
"N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting.
"frequencyDictionary": {
"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
"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`.
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"bandedColors": ["#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.
"secondary": {
"css": {
@@ -430,9 +378,9 @@
"font-kerning": "normal", // Font kerning 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.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
}, // Secondary setting.
}, // Primary and secondary subtitle styling.
// ==========================================
@@ -457,8 +405,8 @@
"--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-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.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
"--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.
}, // 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.
"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.
"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.
// ==========================================
@@ -490,11 +438,9 @@
"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.
"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.
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"tags": ["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.
"fields": {
"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.
"sentence": "Sentence", // Card field that receives the source sentence text.
"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.
"ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
"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.
"media": {
"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
"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.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
"maxMediaDuration": 30, // Maximum allowed media clip duration in seconds.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"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
"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.
"behavior": {
"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
"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
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
"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.
"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.
"isLapis": {
"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.
"isKiku": {
"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
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
"deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
}, // 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.
"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
"maxEntryResults": 10 // Maximum Jimaku search results returned.
"maxEntryResults": 10, // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
@@ -578,10 +524,7 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ==========================================
@@ -599,9 +542,9 @@
"collapsibleSections": {
"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
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting.
} // Character dictionary setting.
"voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
}, // Collapsible sections setting.
}, // Character dictionary setting.
}, // Anilist API credentials and update behavior.
// ==========================================
@@ -612,7 +555,7 @@
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ==========================================
"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.
// ==========================================
@@ -634,7 +577,7 @@
"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.
"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.
// ==========================================
@@ -655,16 +598,8 @@
"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.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
"directPlayContainers": ["mkv", "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.
// ==========================================
@@ -676,7 +611,7 @@
"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
"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.
// ==========================================
@@ -701,13 +636,13 @@
"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.
"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.
"lifetimeSummaries": {
"global": true, // Maintain global 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
} // Lifetime summaries setting.
"media": true, // Maintain per-media lifetime stats rows. Values: true | false
}, // Lifetime summaries setting.
}, // 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.
"serverPort": 6969, // Port for the stats HTTP server.
"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
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
"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.
}
+157 -156
View File
@@ -272,8 +272,8 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- |
| Option | Values | Description |
| ------------------- | ------------------------- | --------------------------------------------------- |
| `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `websocket.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.port` | number | Annotation websocket port (default: 6678) |
@@ -358,34 +358,35 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| 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`. |
| `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) |
| `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). |
| `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) |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` 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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| 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`. |
| `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) |
| `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). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` 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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
| `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:
| CSS Property | Default | Description |
| --------------------------------------------- | ------------- | ---------------------------------------- |
| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color |
| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color |
| CSS Property | Default | Description |
| ----------------------------------------- | ------------- | --------------------------------------- |
| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text 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 primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
@@ -438,25 +439,25 @@ Configure the parsed-subtitle sidebar modal.
}
```
| Option | Values | Description |
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` 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 |
| Option | Values | Description |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` 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 |
| `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) |
| `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. |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
| `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. |
Sidebar CSS custom properties:
| CSS Property | Default | Description |
| ------------------------------------------------- | ------------------------------- | ------------------------------------- |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp 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-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color |
| CSS Property | Default | Description |
| -------------------------------------------- | --------------------------- | ---------------------------- |
| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width |
| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp 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-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.
@@ -621,26 +622,26 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `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"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `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) |
| `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"`) |
| `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`) |
| `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"`) |
| `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"`) |
| `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"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `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. |
| Option | Values | Description |
| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `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"`) |
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
| `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) |
| `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"`) |
| `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`) |
| `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"`) |
| `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"`) |
| `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"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `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. |
**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:
| Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `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+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+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+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+D` | Open loaded character dictionary manager |
| `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) |
| Shortcut | Action |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| `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+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+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+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+D` | Open loaded character dictionary manager |
| `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) |
**Multi-line copy workflow:**
@@ -855,7 +856,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
| 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 |
| `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`) |
@@ -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.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `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) |
| `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.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`) |
| `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. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `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`) |
| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `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.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.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.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.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.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `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) |
| `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.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`) |
| `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. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `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`) |
| `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.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (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.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `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.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.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.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.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.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.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.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.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.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.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`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `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 |
| `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`) |
| `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.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.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.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.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`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `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 |
| `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`) |
`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.
@@ -1144,15 +1145,15 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
}
```
| Option | Values | Description |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `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.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.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 |
| Option | Values | Description |
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `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.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.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 |
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 |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL |
| `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` |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support |
| Option | Values | Description |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ |
| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL |
| `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` |
| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted |
| `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) |
| `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 |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
| `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 |
| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons |
| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding |
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `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.
@@ -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`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps:
+83 -148
View File
@@ -5,7 +5,6 @@
* Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.
*/
{
// ==========================================
// Visible Overlay Auto-Start
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
@@ -19,7 +18,7 @@
// ==========================================
"texthooker": {
"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.
// ==========================================
@@ -29,7 +28,7 @@
// ==========================================
"websocket": {
"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.
// ==========================================
@@ -39,7 +38,7 @@
// ==========================================
"annotationWebsocket": {
"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.
// ==========================================
@@ -54,8 +53,8 @@
"files": {
"app": true, // Write SubMiner app runtime 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
} // Files setting.
"mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false
}, // Files setting.
}, // Controls logging verbosity.
// ==========================================
@@ -88,66 +87,66 @@
"leftStickPress": 9, // Raw button index used for controller L3 input.
"rightStickPress": 10, // Raw button index used for controller R3 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.
"bindings": {
"toggleLookup": {
"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.
"closeLookup": {
"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.
"toggleKeyboardOnlyMode": {
"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.
"mineCard": {
"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.
"quitMpv": {
"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.
"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.
"nextAudio": {
"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.
"playCurrentAudio": {
"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.
"toggleMpvPause": {
"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.
"leftStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"leftStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"rightStickHorizontal": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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.
"rightStickVertical": {
"kind": "axis", // Analog binding input source kind. Values: none | axis
"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
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
"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.
}, // 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.
// ==========================================
@@ -161,7 +160,7 @@
"mecab": true, // Warm up MeCab tokenizer 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
"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.
// ==========================================
@@ -173,7 +172,7 @@
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"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.
// ==========================================
@@ -199,7 +198,7 @@
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"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.
"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.
// ==========================================
@@ -211,122 +210,76 @@
"keybindings": [
{
"key": "Space", // Key setting.
"command": [
"cycle",
"pause"
] // Command setting.
"command": ["cycle", "pause"], // Command setting.
},
{
"key": "KeyF", // Key setting.
"command": [
"cycle",
"fullscreen"
] // Command setting.
"command": ["cycle", "fullscreen"], // Command setting.
},
{
"key": "KeyJ", // Key setting.
"command": [
"cycle",
"sid"
] // Command setting.
"command": ["cycle", "sid"], // Command setting.
},
{
"key": "Shift+KeyJ", // Key setting.
"command": [
"cycle",
"secondary-sid"
] // Command setting.
"command": ["cycle", "secondary-sid"], // Command setting.
},
{
"key": "ArrowRight", // Key setting.
"command": [
"seek",
5
] // Command setting.
"command": ["seek", 5], // Command setting.
},
{
"key": "ArrowLeft", // Key setting.
"command": [
"seek",
-5
] // Command setting.
"command": ["seek", -5], // Command setting.
},
{
"key": "ArrowUp", // Key setting.
"command": [
"seek",
60
] // Command setting.
"command": ["seek", 60], // Command setting.
},
{
"key": "ArrowDown", // Key setting.
"command": [
"seek",
-60
] // Command setting.
"command": ["seek", -60], // Command setting.
},
{
"key": "Shift+KeyH", // Key setting.
"command": [
"sub-seek",
-1
] // Command setting.
"command": ["sub-seek", -1], // Command setting.
},
{
"key": "Shift+KeyL", // Key setting.
"command": [
"sub-seek",
1
] // Command setting.
"command": ["sub-seek", 1], // Command setting.
},
{
"key": "Shift+BracketRight", // Key setting.
"command": [
"__sub-delay-next-line"
] // Command setting.
"command": ["__sub-delay-next-line"], // Command setting.
},
{
"key": "Shift+BracketLeft", // Key setting.
"command": [
"__sub-delay-prev-line"
] // Command setting.
"command": ["__sub-delay-prev-line"], // Command setting.
},
{
"key": "Ctrl+Alt+KeyC", // Key setting.
"command": [
"__youtube-picker-open"
] // Command setting.
"command": ["__youtube-picker-open"], // Command setting.
},
{
"key": "Ctrl+Alt+KeyP", // Key setting.
"command": [
"__playlist-browser-open"
] // Command setting.
"command": ["__playlist-browser-open"], // Command setting.
},
{
"key": "Ctrl+Shift+KeyH", // Key setting.
"command": [
"__replay-subtitle"
] // Command setting.
"command": ["__replay-subtitle"], // Command setting.
},
{
"key": "Ctrl+Shift+KeyL", // Key setting.
"command": [
"__play-next-subtitle"
] // Command setting.
"command": ["__play-next-subtitle"], // Command setting.
},
{
"key": "KeyQ", // Key setting.
"command": [
"quit"
] // Command setting.
"command": ["quit"], // Command setting.
},
{
"key": "Ctrl+KeyW", // Key setting.
"command": [
"quit"
] // Command setting.
}
"command": ["quit"], // Command setting.
},
], // Default and custom keybindings that are merged with built-in defaults.
// ==========================================
@@ -338,7 +291,7 @@
"secondarySub": {
"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
"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.
// ==========================================
@@ -350,7 +303,7 @@
"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.
"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.
// ==========================================
@@ -358,7 +311,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"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.
// ==========================================
@@ -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.
"backdrop-filter": "blur(6px)", // Backdrop filter 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.
"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
"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
"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
"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.
@@ -399,7 +353,7 @@
"N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting.
"N4": "#8bd5ca", // N4 setting.
"N5": "#8aadf4" // N5 setting.
"N5": "#8aadf4", // N5 setting.
}, // Jlpt colors setting.
"frequencyDictionary": {
"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
"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`.
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"bandedColors": ["#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.
"secondary": {
"css": {
@@ -430,9 +378,9 @@
"font-kerning": "normal", // Font kerning 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.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
}, // Secondary setting.
}, // Primary and secondary subtitle styling.
// ==========================================
@@ -457,8 +405,8 @@
"--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-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.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
"--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.
}, // 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.
"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.
"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.
// ==========================================
@@ -490,11 +438,9 @@
"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.
"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.
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"tags": ["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.
"fields": {
"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.
"sentence": "Sentence", // Card field that receives the source sentence text.
"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.
"ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
"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.
"media": {
"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
"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.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
"maxMediaDuration": 30, // Maximum allowed media clip duration in seconds.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"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
"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.
"behavior": {
"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
"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
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
"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.
"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.
"isLapis": {
"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.
"isKiku": {
"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
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
"deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
}, // 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.
"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
"maxEntryResults": 10 // Maximum Jimaku search results returned.
"maxEntryResults": 10, // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
@@ -578,10 +524,7 @@
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ==========================================
"youtube": {
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// ==========================================
@@ -599,9 +542,9 @@
"collapsibleSections": {
"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
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting.
} // Character dictionary setting.
"voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
}, // Collapsible sections setting.
}, // Character dictionary setting.
}, // Anilist API credentials and update behavior.
// ==========================================
@@ -612,7 +555,7 @@
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
// ==========================================
"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.
// ==========================================
@@ -634,7 +577,7 @@
"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.
"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.
// ==========================================
@@ -655,16 +598,8 @@
"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.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
"directPlayContainers": ["mkv", "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.
// ==========================================
@@ -676,7 +611,7 @@
"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
"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.
// ==========================================
@@ -701,13 +636,13 @@
"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.
"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.
"lifetimeSummaries": {
"global": true, // Maintain global 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
} // Lifetime summaries setting.
"media": true, // Maintain per-media lifetime stats rows. Values: true | false
}, // Lifetime summaries setting.
}, // 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.
"serverPort": 6969, // Port for the stats HTTP server.
"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
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
"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.
}
+59 -8
View File
@@ -288,6 +288,48 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
assert.equal(privateState.runtime.proxyServer, null);
});
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 () => {
const osdMessages: string[] = [];
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)']);
});
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -340,9 +382,9 @@ test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged Se
assert.equal(
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 () => {
@@ -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>');
});
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 merged = await collaborator.computeFieldGroupingMergedFields(
@@ -400,10 +442,19 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
false,
);
assert.equal(merged.Sentence, '<span data-group-id="202">same sentence</span>');
assert.equal(merged.SentenceAudio, '<span data-group-id="202">[sound:same.mp3]</span>');
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
assert.equal(
merged.Sentence,
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
);
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', () => {
+102 -27
View File
@@ -29,7 +29,7 @@ import {
} from './types/anki';
import { AiConfig } from './types/integrations';
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 {
getConfiguredWordFieldCandidates,
@@ -149,6 +149,7 @@ export class AnkiIntegration {
private aiConfig: AiConfig;
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private noteIdRedirects = new Map<number, number>();
private trackedDuplicateNoteIds = new Map<number, number[]>();
@@ -453,11 +454,13 @@ export class AnkiIntegration {
mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite),
generateAudioFilename: () => this.generateAudioFilename(),
generateAudio: () => this.generateAudio(),
generateAudio: (context) => this.generateAudio(context),
generateImageFilename: () => this.generateImageFilename(),
generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds),
generateImage: (animatedLeadInSeconds, context) =>
this.generateImage(animatedLeadInSeconds, context),
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
showNotification: (noteId, label) => this.showNotification(noteId, label),
showOsdNotification: (message) => this.showOsdNotification(message),
@@ -474,6 +477,7 @@ export class AnkiIntegration {
client: {
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
},
getConfig: () => this.config,
@@ -673,7 +677,55 @@ export class AnkiIntegration {
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;
if (!mpvClient || !mpvClient.currentVideoPath) {
return null;
@@ -683,15 +735,7 @@ export class AnkiIntegration {
if (!videoPath) {
return null;
}
let startTime = mpvClient.currentSubStart;
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;
}
const { startTime, endTime } = this.getSubtitleMediaRange(context);
return this.mediaGenerator.generateAudio(
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) {
return null;
}
@@ -711,22 +758,16 @@ export class AnkiIntegration {
if (!videoPath) {
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') {
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(
videoPath,
startTime,
endTime,
mediaRange.startTime,
mediaRange.endTime,
this.config.media?.audioPadding,
{
fps: this.config.media?.animatedFps,
@@ -1064,18 +1105,48 @@ export class AnkiIntegration {
endTime: number,
secondarySubText?: string,
): Promise<boolean> {
return this.cardCreationService.createSentenceCard(
const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
const created = await this.cardCreationService.createSentenceCard(
sentence,
startTime,
endTime,
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 {
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(
expression: string,
excludeNoteId: number,
@@ -1287,6 +1358,10 @@ export class AnkiIntegration {
this.knownWordCacheUpdatedCallback = callback;
}
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
this.consumeSubtitleMiningContextCallback = callback;
}
resolveCurrentNoteId(noteId: number): number {
let resolved = noteId;
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({
config: {
fields: {
image: 'Illustration',
sentence: 'SentenceText',
audio: 'ExpressionAudio',
audio: 'CustomWordAudio',
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 () => {
const { collaborator } = createCollaborator({
config: {
fields: {
audio: 'CustomAudio',
},
},
});
test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
1,
2,
makeNote(1, {
SentenceAudio: '[sound:keep.mp3]',
CustomAudio: '[sound:stale.mp3]',
300,
200,
makeNote(300, {
Sentence: 'original sentence',
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]',
Picture: '<img src="new.png">',
MiscInfo: 'new misc',
}),
false,
);
assert.equal(
merged.SentenceAudio,
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
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-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 () => {
@@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty
assert.equal(
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(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.MiscInfo, '<span data-group-id="11">generated misc</span>');
});
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: 'original furigana',
}),
makeNote(200, {
SentenceFurigana: '',
}),
false,
);
assert.equal(merged.SentenceFurigana, '');
});
+44 -113
View File
@@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator {
fields.push('Picture');
if (config.fields?.image) fields.push(config.fields?.image);
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 sentenceAudioField = sentenceCardConfig.audioField;
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']) {
sourceFields[configuredWordField] = sourceFields['Expression'];
}
@@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator {
if (!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 (
config.fields?.sentence &&
!sourceFields[config.fields?.sentence] &&
@@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator {
const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName);
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) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
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;
}
@@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator {
}
private extractUngroupedValue(value: string): string {
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\s\S]*?<\/span>/gi;
const ungrouped = value.replace(groupedSpanRegex, '').trim();
const ungrouped = this.extractUngroupedRemainder(value);
if (ungrouped) return ungrouped;
return value.trim();
}
private extractLastSoundTag(value: string): string {
const matches = value.match(/\[sound:[^\]]+\]/g);
if (!matches || matches.length === 0) return '';
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 extractUngroupedRemainder(value: string): string {
const groupedSpanRegex = /<span\b[^>]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
return value.replace(groupedSpanRegex, '').trim();
}
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;
while ((match = spanRegex.exec(value)) !== null) {
const groupId = Number(match[1]);
@@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator {
fieldName: string,
): { groupId: number; content: string }[] {
const entries = this.extractSpanEntries(value, fieldName);
if (entries.length === 0) {
const ungrouped = this.normalizeStrictGroupedValue(
this.extractUngroupedValue(value),
fieldName,
);
if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
const ungroupedSource =
entries.length > 0
? this.extractUngroupedRemainder(value)
: this.extractUngroupedValue(value);
const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName);
if (ungrouped) {
entries.push({ groupId: fallbackGroupId, content: ungrouped });
}
const unique: { groupId: number; content: string }[] = [];
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;
return entries;
}
private parsePictureEntries(
@@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator {
if (!ungrouped) return '';
const normalizedField = fieldName.toLowerCase();
if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') {
const lastSoundTag = this.extractLastSoundTag(ungrouped);
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;
if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return ungrouped;
}
private getPictureDedupKey(tag: string): string {
return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim();
}
private getStrictSpanGroupingFields(): Set<string> {
const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
@@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator {
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(
existingValue: string,
newValue: string,
@@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator {
fieldName: string,
): string {
if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (fieldName.toLowerCase() === 'picture') {
if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId);
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
const mergedTags = keepEntries.map((entry) =>
this.ensureImageGroupId(entry.tag, entry.groupId),
);
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('');
return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries])
.map((entry) => this.ensureImageGroupId(entry.tag, entry.groupId))
.join('');
}
const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName);
@@ -423,19 +366,7 @@ export class FieldGroupingMergeCollaborator {
if (keepEntries.length === 0 && sourceEntries.length === 0) {
return existingValue || newValue;
}
if (sourceEntries.length === 0) {
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);
}
const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]);
if (merged.length === 0) return existingValue;
return merged
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
@@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an
type NoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
tags?: string[];
};
type ManualChoice = {
@@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: {
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const deleted: number[][] = [];
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
const statuses: string[] = [];
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
const mergeCalls: Array<{
@@ -49,6 +51,9 @@ function createWorkflowHarness() {
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
updates.push({ noteId, fields });
},
addTags: async (noteIds: number[], tags: string[]) => {
addedTags.push({ noteIds, tags });
},
deleteNotes: async (noteIds: number[]) => {
deleted.push(noteIds);
},
@@ -117,6 +122,7 @@ function createWorkflowHarness() {
workflow: new FieldGroupingWorkflow(deps),
updates,
deleted,
addedTags,
rememberedMerges,
statuses,
mergeCalls,
@@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
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 () => {
const harness = createWorkflowHarness();
@@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
export interface FieldGroupingWorkflowNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
tags?: string[];
}
export interface FieldGroupingWorkflowDeps {
client: {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
addTags(noteIds: number[], tags: string[]): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>;
};
getConfig: () => {
@@ -156,6 +158,11 @@ export class FieldGroupingWorkflow {
await this.deps.addConfiguredTagsToNote(keepNoteId);
}
const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo);
if (tagsToAdd.length > 0) {
await this.deps.client.addTags([keepNoteId], tagsToAdd);
}
if (deleteDuplicate) {
await this.deps.client.deleteNotes([deleteNoteId]);
this.deps.removeTrackedNoteId(deleteNoteId);
@@ -200,6 +207,24 @@ export class FieldGroupingWorkflow {
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<
| ((data: {
original: KikuDuplicateCardInfo;
@@ -5,6 +5,7 @@ import {
type NoteUpdateWorkflowDeps,
type NoteUpdateWorkflowNoteInfo,
} from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle';
function createWorkflowHarness() {
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);
});
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
const harness = createWorkflowHarness();
const sidebarContext = {
source: 'subtitle-sidebar' as const,
text: 'sidebar previous line',
startTime: 10,
endTime: 12,
capturedAtMs: 123,
};
let audioContext: unknown = null;
let imageContext: unknown = null;
let miscInfoStartTime: number | undefined;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: 'sidebar previous line' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'avif',
},
behavior: {},
});
harness.deps.getCurrentSubtitleText = () => 'current primary line';
harness.deps.getCurrentSubtitleStart = () => 20;
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
audioContext = context ?? null;
return Buffer.from('audio');
};
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
imageContext = context ?? null;
return Buffer.from('image');
};
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
miscInfoStartTime = startTimeSeconds;
return `start:${startTimeSeconds}`;
};
(
harness.deps as NoteUpdateWorkflowDeps & {
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
}
).consumeSubtitleMiningContext = () => sidebarContext;
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
assert.deepEqual(audioContext, sidebarContext);
assert.deepEqual(imageContext, sidebarContext);
assert.equal(miscInfoStartTime, 10);
});
+72 -6
View File
@@ -1,5 +1,6 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
@@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps {
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>;
generateAudio: (context?: SubtitleMiningContext) => Promise<Buffer | null>;
generateImageFilename: () => string;
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
generateImage: (
animatedLeadInSeconds?: number,
context?: SubtitleMiningContext,
) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>;
showOsdNotification: (message: string) => void;
@@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps {
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 {
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> {
this.deps.beginUpdateProgress('Updating card');
try {
@@ -121,8 +179,13 @@ export class NoteUpdateWorkflow {
let updatePerformed = false;
let miscInfoFilename: string | null = null;
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) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
@@ -132,7 +195,7 @@ export class NoteUpdateWorkflow {
if (config.media?.generateAudio) {
try {
const audioFilename = this.deps.generateAudioFilename();
const audioBuffer = await this.deps.generateAudio();
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
@@ -158,7 +221,10 @@ export class NoteUpdateWorkflow {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds);
const imageBuffer = await this.deps.generateImage(
animatedLeadInSeconds,
subtitleMiningContext ?? undefined,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -189,7 +255,7 @@ export class NoteUpdateWorkflow {
if (config.fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || '',
this.deps.getCurrentSubtitleStart(),
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
);
const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo,
+39
View File
@@ -104,6 +104,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
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', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
primaryVisibleOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false,
@@ -57,6 +57,13 @@ export function buildSubtitleConfigOptionRegistry(
description:
'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',
kind: 'string',
+23
View File
@@ -186,6 +186,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor =
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(
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
);
+27
View File
@@ -128,6 +128,33 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
);
});
test('subtitleStyle primaryVisibleOnYomitanPopup falls back on invalid value', () => {
const valid = createResolveContext({
subtitleStyle: {
primaryVisibleOnYomitanPopup: false,
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, false);
const { context, warnings } = createResolveContext({
subtitleStyle: {
primaryVisibleOnYomitanPopup: 'invalid' as unknown as boolean,
},
});
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, true);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup' &&
warning.message === 'Expected boolean.',
),
);
});
test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
+10 -1
View File
@@ -15,6 +15,8 @@ test('settings registry splits viewing into appearance and behavior categories',
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
assert.equal(field('subtitleStyle.primaryDefaultMode').category, '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('subtitlePosition.yPercent').label, 'Subtitle Position');
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.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
fields.findIndex(
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
),
);
assert.ok(
fields.findIndex(
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
});
+9 -1
View File
@@ -168,6 +168,7 @@ const PATH_ORDER = new Map<string, number>(
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode',
'subtitleStyle.primaryVisibleOnYomitanPopup',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
@@ -218,6 +219,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'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.',
'subtitleSidebar.css':
'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':
'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':
@@ -359,7 +363,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
}
if (path === 'subtitleStyle.primaryDefaultMode') {
if (
path === 'subtitleStyle.primaryDefaultMode' ||
path === 'subtitleStyle.primaryVisibleOnYomitanPopup'
) {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleStyle.')) {
@@ -603,6 +610,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
}
function fieldTypeRank(field: ConfigSettingsField): number {
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1;
}
@@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
assert.equal(visible, false);
assert.deepEqual(visibilityTransitions, [true, false]);
});
async function settleWithinMicrotasks<T>(
promise: Promise<T>,
attempts = 10,
): Promise<T | 'timeout'> {
let settled = false;
let settledValue: T | undefined;
void promise.then((value) => {
settled = true;
settledValue = value;
});
for (let i = 0; i < attempts; i += 1) {
await Promise.resolve();
if (settled) {
return settledValue as T;
}
}
return 'timeout';
}
test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const sends: Array<{
channel: string;
payload: unknown;
restoreOnModalClose?: string;
preferModalWindow?: boolean;
}> = [];
const waitCalls: Array<{ modal: string; timeoutMs: number }> = [];
const warnings: string[] = [];
const closed: string[] = [];
const originalSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
try {
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
getMainWindow: () => null,
getVisibleOverlayVisible: () => true,
setVisibleOverlayVisible: () => {},
getResolver: () => resolver,
setResolver: (nextResolver) => {
resolver = nextResolver;
},
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
sendToVisibleOverlay: (channel, payload, runtimeOptions) => {
sends.push({
channel,
payload,
restoreOnModalClose: runtimeOptions?.restoreOnModalClose,
preferModalWindow: runtimeOptions?.preferModalWindow,
});
return true;
},
waitForModalOpen: async (modal, timeoutMs) => {
waitCalls.push({ modal, timeoutMs });
return false;
},
handleOverlayModalClosed: (modal) => {
closed.push(modal);
},
logWarn: (message) => {
warnings.push(message);
},
});
const request = {
original: {
noteId: 1,
expression: 'a',
sentencePreview: 'a',
hasAudio: false,
hasImage: false,
isOriginal: true,
},
duplicate: {
noteId: 2,
expression: 'b',
sentencePreview: 'b',
hasAudio: false,
hasImage: false,
isOriginal: false,
},
};
const pendingChoice = runtime.createFieldGroupingCallback()(request);
const result = await settleWithinMicrotasks(pendingChoice);
assert.notEqual(result, 'timeout');
assert.deepEqual(result, {
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
assert.equal(resolver, null);
assert.deepEqual(
sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({
channel,
restoreOnModalClose,
preferModalWindow,
})),
[
{
channel: 'kiku:field-grouping-request',
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
{
channel: 'kiku:field-grouping-request',
restoreOnModalClose: 'kiku',
preferModalWindow: true,
},
],
);
assert.deepEqual(waitCalls, [
{ modal: 'kiku', timeoutMs: 1500 },
{ modal: 'kiku', timeoutMs: 1500 },
]);
assert.deepEqual(warnings, [
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
assert.deepEqual(closed, ['kiku']);
} finally {
globalThis.setTimeout = originalSetTimeout;
}
});
+48 -3
View File
@@ -8,6 +8,10 @@ interface WindowLike {
};
}
const KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS = 1500;
const KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING =
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.';
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisible: () => boolean;
@@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
handleOverlayModalClosed?: (modal: T) => void;
logWarn?: (message: string) => void;
sendToVisibleOverlay?: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
}
@@ -28,7 +35,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
@@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
const sendToVisibleOverlay = (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
): boolean => {
if (options.sendToVisibleOverlay) {
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 = (): ((
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>) => {
@@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
getResolver: options.getResolver,
setResolver: options.setResolver,
sendToVisibleOverlay,
sendKikuFieldGroupingRequest,
});
};
+32 -14
View File
@@ -5,7 +5,7 @@ export function createFieldGroupingCallback(options: {
setVisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
return new Promise((resolve) => {
@@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: {
const previousVisibleOverlay = options.getVisibleOverlayVisible();
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (choice: KikuFieldGroupingChoice): void => {
if (settled) return;
settled = true;
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
if (options.getResolver() === finish) {
options.setResolver(null);
}
@@ -36,25 +41,38 @@ export function createFieldGroupingCallback(options: {
};
options.setResolver(finish);
if (!options.sendRequestToVisibleOverlay(data)) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
setTimeout(() => {
if (!settled) {
void Promise.resolve(options.sendRequestToVisibleOverlay(data)).then(
(sent) => {
if (settled) return;
if (!sent) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
return;
}
timeout = setTimeout(() => {
if (!settled) {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
},
() => {
finish({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: true,
cancelled: true,
});
}
}, 90000);
},
);
});
};
}
+37
View File
@@ -630,6 +630,43 @@ test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion
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 () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(createRegisterIpcDeps(), registrar);
+43 -1
View File
@@ -9,6 +9,7 @@ import type {
ResolvedControllerConfig,
RuntimeOptionId,
RuntimeOptionValue,
SubtitleMiningContext,
SubtitleSidebarSnapshot,
SubtitlePosition,
SubsyncManualRunRequest,
@@ -95,6 +96,7 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (
mediaId: number,
@@ -175,6 +177,43 @@ interface IpcMainRegistrar {
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 {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
@@ -230,6 +269,7 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (
mediaId: number,
@@ -257,6 +297,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
toggleDevTools: () => {
const mainWindow = options.getMainWindow();
@@ -423,7 +464,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
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();
});
+7 -4
View File
@@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
) => boolean;
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
@@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getResolver: options.getResolver,
setResolver: options.setResolver,
sendRequestToVisibleOverlay: (data) =>
options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: 'kiku' as T,
}),
options.sendKikuFieldGroupingRequest
? options.sendKikuFieldGroupingRequest(data)
: options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
restoreOnModalClose: 'kiku' as T,
}),
});
}
+60
View File
@@ -25,6 +25,7 @@ interface YomitanTokenInput {
surface: string;
reading?: string;
headword?: string;
frequencyRank?: number;
isNameMatch?: boolean;
wordClasses?: string[];
}
@@ -57,6 +58,7 @@ function makeDepsFromYomitanTokens(
startPos,
endPos,
isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
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);
});
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 () => {
const result = await tokenizeSubtitle(
'第二走者',
@@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
if (parts.length === 0) {
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.some((part) => exclusions.has(part));
return parts.every((part) => exclusions.has(part));
}
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
@@ -227,6 +226,10 @@ function isFrequencyExcludedByPos(
return true;
}
if (isKanaOnlyMixedFunctionContentToken(token, pos1Exclusions)) {
return true;
}
const normalizedPos1 = normalizePos1Tag(token.pos1);
const hasPos1 = normalizedPos1.length > 0;
const normalizedPos2 = normalizePos2Tag(token.pos2);
@@ -564,6 +567,35 @@ function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean {
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 {
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
return false;
+19 -2
View File
@@ -113,6 +113,7 @@ import type {
SecondarySubMode,
SubtitleCue,
SubtitleData,
SubtitleMiningContext,
SubtitlePosition,
UpdateChannel,
WindowGeometry,
@@ -730,8 +731,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
const texthookerService = new Texthooker(() => {
const config = getResolvedConfig();
const characterDictionaryEnabled =
config.subtitleStyle.nameMatchEnabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownWordColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
config.ankiConnect.knownWords.highlightEnabled,
@@ -908,6 +908,7 @@ const {
appState,
appLifecycleApp,
} = bootServices;
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
notifyAnilistTokenStoreWarning = (message: string) => {
logger.warn(`[AniList] ${message}`);
@@ -2181,6 +2182,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
setResolver: (resolver) => setFieldGroupingResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () =>
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
logWarn: (message) => logger.warn(message),
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
})(),
@@ -4190,6 +4194,14 @@ const immersionTrackerStartupMainDeps: Parameters<
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
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 => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
@@ -5153,6 +5165,7 @@ function initializeOverlayRuntime(): void {
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
syncOverlayMpvSubtitleSuppression();
}
@@ -5948,6 +5961,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => {
@@ -6198,6 +6212,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext,
);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification,
+2
View File
@@ -96,6 +96,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
recordSubtitleMiningContext?: IpcDepsRuntimeOptions['recordSubtitleMiningContext'];
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot'];
@@ -273,6 +274,7 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow,
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
recordSubtitleMiningContext: params.recordSubtitleMiningContext,
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot,
+22
View File
@@ -804,6 +804,28 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => {
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 () => {
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
+7
View File
@@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService(
): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
const openedModals = new Set<OverlayHostedModal>();
let modalActive = false;
let mainWindowMousePassthroughForcedByModal = false;
let mainWindowHiddenByModal = false;
@@ -375,6 +376,7 @@ export function createOverlayModalRuntimeService(
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
openedModals.delete(modal);
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
restoreVisibleOverlayOnModalClose.delete(modal);
const modalWindow = deps.getModalWindow();
@@ -392,6 +394,7 @@ export function createOverlayModalRuntimeService(
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
openedModals.add(modal);
const waiters = modalOpenWaiters.get(modal) ?? [];
modalOpenWaiters.delete(modal);
for (const resolve of waiters) {
@@ -420,6 +423,10 @@ export function createOverlayModalRuntimeService(
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
await new Promise<boolean>((resolve) => {
if (openedModals.has(modal)) {
resolve(true);
return;
}
const waiters = modalOpenWaiters.get(modal) ?? [];
const finish = (opened: boolean): void => {
clearTimeout(timeout);
@@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps<TModal extends string> = Omit<
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
) => boolean;
};
@@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
});
}
+27
View File
@@ -11,6 +11,7 @@ type LogCandidate = {
mtimeMs: number;
mtimeDateKey: string;
fileDateKey: string | null;
fileWeekKey: string | null;
};
export type ExportLogsResult = {
@@ -38,10 +39,21 @@ function localDateKey(date: Date): string {
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 {
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 {
const match = fileName.match(/^([A-Za-z0-9_-]+)-/);
return match?.[1] ?? fileName;
@@ -84,6 +96,7 @@ function buildCandidate(logsDir: string, entry: string): LogCandidate | null {
mtimeMs: stats.mtimeMs,
mtimeDateKey: localDateKey(stats.mtime),
fileDateKey: filenameDateKey(entry),
fileWeekKey: filenameWeekKey(entry),
};
}
@@ -117,6 +130,14 @@ function candidateFreshnessMs(candidate: LogCandidate): number {
if (candidate.fileDateKey) {
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;
}
@@ -130,6 +151,12 @@ function selectLogCandidates(
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(
(candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today,
);
+3 -2
View File
@@ -55,6 +55,7 @@ import type {
ControllerPreferenceUpdate,
ResolvedControllerConfig,
SessionNumericSelectionStartPayload,
SubtitleMiningContext,
YoutubePickerOpenPayload,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
@@ -262,8 +263,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
},
recordYomitanLookup: () => {
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup);
recordYomitanLookup: (context?: SubtitleMiningContext | null) => {
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null);
},
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
+83
View File
@@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
}
});
test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => {
const ctx = createMouseTestContext();
(ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true;
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: unknown }).document;
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousNode = (globalThis as { Node?: unknown }).Node;
const windowListeners = new Map<string, Array<() => void>>();
const bodyClassList = createClassList();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
addEventListener: (type: string, listener: () => void) => {
const bucket = windowListeners.get(type) ?? [];
bucket.push(listener);
windowListeners.set(type, bucket);
},
electronAPI: {
setIgnoreMouseEvents: () => {},
},
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
body: { classList: bodyClassList },
querySelectorAll: () => [],
querySelector: () => null,
visibilityState: 'visible',
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
},
});
Object.defineProperty(globalThis, 'Node', {
configurable: true,
value: {
ELEMENT_NODE: 1,
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupYomitanObserver();
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true);
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
listener();
}
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: previousMutationObserver,
});
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
+13
View File
@@ -5,6 +5,7 @@ import {
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
isYomitanPopupVisible,
isYomitanPopupIframe,
} from '../yomitan-popup.js';
@@ -44,10 +45,21 @@ export function createMouseHandlers(
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 {
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
yomitanPopupVisible = popupVisible;
ctx.state.yomitanPopupVisible = popupVisible;
syncPrimaryVisibleOnYomitanPopupClass(popupVisible);
return popupVisible;
}
@@ -293,6 +305,7 @@ export function createMouseHandlers(
yomitanPopupVisible = false;
ctx.state.yomitanPopupVisible = false;
syncPrimaryVisibleOnYomitanPopupClass(false);
popupPauseRequestId += 1;
maybeResumeYomitanPopupPause();
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);
});
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', () => {
const removed: string[] = [];
const style = {
+84 -1
View File
@@ -1,4 +1,9 @@
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
import type {
SubtitleCue,
SubtitleData,
SubtitleMiningContext,
SubtitleSidebarSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
@@ -201,6 +206,7 @@ export function createSubtitleSidebarModal(
let subtitleSidebarFocusedWithin = false;
let subtitleSidebarYomitanPopupVisible = false;
let subtitleSidebarPauseHeldByYomitanPopup = false;
let lastSubtitleSidebarLookupCueIndex = -1;
function restoreEmbeddedSidebarPassthrough(): void {
syncOverlayMouseIgnoreState(ctx);
@@ -213,9 +219,75 @@ export function createSubtitleSidebarModal(
function clearSidebarInteractionState(): void {
subtitleSidebarHovered = false;
subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
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 {
ctx.dom.subtitleSidebarStatus.textContent = message;
}
@@ -653,6 +725,12 @@ export function createSubtitleSidebarModal(
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
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 () => {
subtitleSidebarHovered = true;
syncSidebarInteractionState();
@@ -677,6 +755,9 @@ export function createSubtitleSidebarModal(
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
subtitleSidebarHovered = false;
if (!subtitleSidebarFocusedWithin) {
lastSubtitleSidebarLookupCueIndex = -1;
}
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
@@ -700,6 +781,7 @@ export function createSubtitleSidebarModal(
}
subtitleSidebarFocusedWithin = false;
lastSubtitleSidebarLookupCueIndex = -1;
syncSidebarInteractionState();
if (ctx.state.isOverSubtitleSidebar) {
restoreEmbeddedSidebarPassthrough();
@@ -736,5 +818,6 @@ export function createSubtitleSidebarModal(
},
handleSubtitleUpdated,
seekToCue,
getSubtitleSidebarMiningContext,
};
}
+1 -1
View File
@@ -580,7 +580,7 @@ registerModalOpenHandlers();
registerKeyboardCommandHandlers();
registerYomitanLookupListener(window, () => {
runGuarded('yomitan:lookup', () => {
window.electronAPI.recordYomitanLookup();
window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
});
});
+2
View File
@@ -114,6 +114,7 @@ export type RendererState = {
preserveSubtitleLineBreaks: boolean;
autoPauseVideoOnSubtitleHover: boolean;
autoPauseVideoOnYomitanPopup: boolean;
primaryVisibleOnYomitanPopup: boolean;
frequencyDictionaryEnabled: boolean;
frequencyDictionaryTopX: number;
frequencyDictionaryMode: 'single' | 'banded';
@@ -225,6 +226,7 @@ export function createRendererState(): RendererState {
preserveSubtitleLineBreaks: false,
autoPauseVideoOnSubtitleHover: false,
autoPauseVideoOnYomitanPopup: false,
primaryVisibleOnYomitanPopup: true,
frequencyDictionaryEnabled: false,
frequencyDictionaryTopX: 1000,
frequencyDictionaryMode: 'single',
+4
View File
@@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
opacity: 1;
}
body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover {
opacity: 1;
}
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
+6
View File
@@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/);
const primaryHoverYomitanPopupVisibleBlock = extractClassBlock(
cssText,
'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover',
);
assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/);
const secondaryEmbeddedHoverBlock = extractClassBlock(
cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
+10
View File
@@ -6,6 +6,7 @@ import type {
SubtitleRendererStyleConfig,
} from '../types';
import type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
type FrequencyRenderSettings = {
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(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
@@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? 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-n2-color', jlptColors.N2);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
+1
View File
@@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
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(
target: EventTarget = window,
+2 -1
View File
@@ -29,6 +29,7 @@ import type {
ResolvedSubtitleSidebarConfig,
SecondarySubMode,
SubtitleData,
SubtitleMiningContext,
SubtitlePosition,
SubtitleSidebarSnapshot,
SubtitleRendererStyleConfig,
@@ -413,7 +414,7 @@ export interface ElectronAPI {
onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
openYomitanSettings: () => void;
recordYomitanLookup: () => void;
recordYomitanLookup: (context?: SubtitleMiningContext | null) => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => Promise<MecabStatus>;
+9
View File
@@ -81,6 +81,7 @@ export interface SubtitleStyleConfig {
preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
autoPauseVideoOnYomitanPopup?: boolean;
primaryVisibleOnYomitanPopup?: boolean;
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
nameMatchEnabled?: boolean;
@@ -217,6 +218,14 @@ export interface SubtitleSidebarSnapshot {
config: SubtitleSidebarSnapshotConfig;
}
export interface SubtitleMiningContext {
source: 'subtitle-sidebar';
text: string;
startTime: number;
endTime: number;
capturedAtMs?: number;
}
export interface SubtitleHoverTokenPayload {
tokenIndex: number | null;
}