mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-28 00:55:16 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ff4d38e5be
|
|||
|
c7fc328194
|
|||
|
edb1da2993
|
|||
|
71ea5ef944
|
|||
|
b076e8800f
|
|||
|
10d9c38037
|
|||
|
c369841827
|
|||
|
c6537224f2
|
|||
|
6ba91780c1
|
|||
|
81830b3372
|
|||
|
3447103857
|
|||
|
bcbd0173e5
|
|||
|
0298a066ad
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Updated the generated example config to use the same CSS declaration paths written by the Settings window for subtitle and sidebar appearance.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: settings
|
||||||
|
|
||||||
|
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with warnings.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Fixed Linux automatic update checks to avoid Electron networking, preventing native Electron network-service crashes during video startup.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Managed bundled mpv plugin startup options from SubMiner config.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Defaulted the note-fields note type picker to the configured Anki deck's note type when available, then exact `Kiku`, then exact `Lapis`, otherwise leaving it blank for manual selection.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut by transporting Linux AppImage control args safely, restoring mpv subtitle visibility during shutdown, snapshotting subtitles before overlay suppression resumes, reapplying Linux overlay bounds after the restarted window maps, allowing Hyprland to resize the visible overlay window, and preserving user-paused playback while readiness gates clear.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Added `subtitleSidebar.css`, migrated legacy sidebar appearance fields into it, and updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
|
||||||
+67
-52
@@ -7,10 +7,11 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Visible Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
|
// SubMiner can still auto-start in the background when this is false.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
|
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
@@ -360,29 +361,31 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
"color": "#cad3f5", // Color setting.
|
||||||
|
"background-color": "transparent", // Background color setting.
|
||||||
|
"font-size": "35px", // Font size setting.
|
||||||
|
"font-weight": "600", // Font weight setting.
|
||||||
|
"font-style": "normal", // Font style setting.
|
||||||
|
"line-height": "1.35", // Line height setting.
|
||||||
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"word-spacing": "0", // Word spacing setting.
|
||||||
|
"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.
|
||||||
|
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||||
|
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // 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
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"fontSize": 35, // Font size setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"fontWeight": "600", // Font weight setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -406,19 +409,21 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"css": {
|
||||||
"fontSize": 24, // Font size setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"color": "#cad3f5", // Color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"background-color": "transparent", // Background color setting.
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
"font-size": "24px", // Font size setting.
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
"font-weight": "600", // Font weight setting.
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
"font-style": "normal", // Font style setting.
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
"line-height": "1.35", // Line height setting.
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"word-spacing": "0", // Word spacing setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"fontWeight": "600", // Font weight setting.
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
"fontStyle": "normal" // Font style 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.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -434,16 +439,18 @@
|
|||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
"css": {
|
||||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
"color": "#cad3f5", // Color setting.
|
||||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
"font-size": "16px", // Font size setting.
|
||||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
"opacity": "0.95", // Opacity setting.
|
||||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
"--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.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -512,8 +519,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||||
"color": "#a6da95" // Color used for known-word highlights.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
@@ -524,15 +530,15 @@
|
|||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
@@ -598,14 +604,23 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||||
|
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||||
|
"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.
|
||||||
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
@@ -648,7 +663,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
+62
-50
@@ -8,10 +8,6 @@ outline: [2, 3]
|
|||||||
import { withBase } from 'vitepress';
|
import { withBase } from 'vitepress';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
|
||||||
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
|
||||||
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
For most users, start with this minimal configuration:
|
For most users, start with this minimal configuration:
|
||||||
@@ -39,9 +35,38 @@ For most users, start with this minimal configuration:
|
|||||||
|
|
||||||
Then customize as needed using the sections below.
|
Then customize as needed using the sections below.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
||||||
|
|
||||||
|
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
||||||
|
|
||||||
|
- Appearance
|
||||||
|
- Behavior
|
||||||
|
- Mining & Anki
|
||||||
|
- Playback & Sources
|
||||||
|
- Input
|
||||||
|
- Integrations
|
||||||
|
- Tracking & App
|
||||||
|
- Advanced
|
||||||
|
|
||||||
|
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||||
|
|
||||||
|
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
||||||
|
|
||||||
|
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||||
|
|
||||||
|
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|
||||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
|
||||||
|
|
||||||
|
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
||||||
|
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
||||||
|
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
||||||
|
|
||||||
|
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||||
|
|
||||||
Generate a fresh default config from the centralized config registry:
|
Generate a fresh default config from the centralized config registry:
|
||||||
|
|
||||||
@@ -63,28 +88,6 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
|
|||||||
|
|
||||||
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
||||||
|
|
||||||
### Configuration Window
|
|
||||||
|
|
||||||
SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape:
|
|
||||||
|
|
||||||
- Viewing
|
|
||||||
- Mining & Anki
|
|
||||||
- Playback & Sources
|
|
||||||
- Input
|
|
||||||
- Integrations
|
|
||||||
- Tracking & App
|
|
||||||
- Advanced
|
|
||||||
|
|
||||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`.
|
|
||||||
|
|
||||||
The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
|
||||||
|
|
||||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
|
||||||
|
|
||||||
Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys.
|
|
||||||
|
|
||||||
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
|
||||||
|
|
||||||
### Hot-Reload Behavior
|
### Hot-Reload Behavior
|
||||||
|
|
||||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||||
@@ -323,25 +326,29 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
|
||||||
"fontSize": 35,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"fontWeight": "600",
|
|
||||||
"lineHeight": 1.35,
|
|
||||||
"letterSpacing": "-0.01em",
|
|
||||||
"wordSpacing": 0,
|
|
||||||
"fontKerning": "normal",
|
|
||||||
"textRendering": "geometricPrecision",
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
|
||||||
"fontStyle": "normal",
|
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"backdropFilter": "blur(6px)",
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
|
"font-size": "35px",
|
||||||
|
"font-weight": "600",
|
||||||
|
"line-height": "1.35",
|
||||||
|
"letter-spacing": "-0.01em",
|
||||||
|
"word-spacing": "0",
|
||||||
|
"font-kerning": "normal",
|
||||||
|
"text-rendering": "geometricPrecision",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||||
|
"font-style": "normal",
|
||||||
|
"backdrop-filter": "blur(6px)"
|
||||||
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
"backgroundColor": "transparent",
|
||||||
"backgroundColor": "transparent"
|
"css": {
|
||||||
|
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||||
|
"font-size": "24px",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +359,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
|
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||||
@@ -363,6 +371,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
|
||||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `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.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.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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
@@ -370,10 +380,13 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `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 |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
|
||||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||||
|
|
||||||
|
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
||||||
|
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||||
|
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
|
||||||
|
`textShadow` remain supported for hand-written or older configs.
|
||||||
|
|
||||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||||
|
|
||||||
@@ -963,11 +976,10 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||||
@@ -1009,9 +1021,9 @@ Known-word cache policy:
|
|||||||
|
|
||||||
- Initial sync runs when the integration starts if the cache is missing or stale.
|
- Initial sync runs when the integration starts if the cache is missing or stale.
|
||||||
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
||||||
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
||||||
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
|
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||||
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||||
@@ -1255,7 +1267,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||||
|
|
||||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window.
|
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
|
||||||
|
|
||||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Visible Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
|
// SubMiner can still auto-start in the background when this is false.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
|
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
@@ -360,29 +361,31 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
"color": "#cad3f5", // Color setting.
|
||||||
|
"background-color": "transparent", // Background color setting.
|
||||||
|
"font-size": "35px", // Font size setting.
|
||||||
|
"font-weight": "600", // Font weight setting.
|
||||||
|
"font-style": "normal", // Font style setting.
|
||||||
|
"line-height": "1.35", // Line height setting.
|
||||||
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"word-spacing": "0", // Word spacing setting.
|
||||||
|
"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.
|
||||||
|
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||||
|
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // 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
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"fontSize": 35, // Font size setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"fontWeight": "600", // Font weight setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -406,19 +409,21 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"css": {
|
||||||
"fontSize": 24, // Font size setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"color": "#cad3f5", // Color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"background-color": "transparent", // Background color setting.
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
"font-size": "24px", // Font size setting.
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
"font-weight": "600", // Font weight setting.
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
"font-style": "normal", // Font style setting.
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
"line-height": "1.35", // Line height setting.
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"word-spacing": "0", // Word spacing setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"fontWeight": "600", // Font weight setting.
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
"fontStyle": "normal" // Font style 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.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -434,16 +439,18 @@
|
|||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
"css": {
|
||||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
"color": "#cad3f5", // Color setting.
|
||||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
"font-size": "16px", // Font size setting.
|
||||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
"opacity": "0.95", // Opacity setting.
|
||||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
"--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.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -512,8 +519,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||||
"color": "#a6da95" // Color used for known-word highlights.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
@@ -524,15 +530,15 @@
|
|||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
@@ -598,14 +604,23 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||||
|
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||||
|
"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.
|
||||||
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
@@ -648,7 +663,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
|||||||
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
||||||
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
||||||
3. When a subtitle line appears, each token is checked against the cache.
|
3. When a subtitle line appears, each token is checked against the cache.
|
||||||
4. If exactly one unknown word remains in the sentence, it is highlighted with `nPlusOneColor` (default: `#c6a0f6`).
|
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
|
||||||
5. Already-known tokens can optionally display in `knownWordColor` (default: `#a6da95`).
|
5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`).
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
|||||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||||
| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
|
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ Notes:
|
|||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||||
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||||
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
|
||||||
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||||
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
||||||
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
||||||
|
|||||||
@@ -567,9 +567,11 @@ export function buildSubminerScriptOpts(
|
|||||||
logLevel: LogLevel = 'info',
|
logLevel: LogLevel = 'info',
|
||||||
extraParts: string[] = [],
|
extraParts: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
|
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||||
|
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||||
const parts = [
|
const parts = [
|
||||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
|
||||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||||
...extraParts.map(sanitizeScriptOptValue),
|
...extraParts.map(sanitizeScriptOptValue),
|
||||||
];
|
];
|
||||||
if (logLevel !== 'info') {
|
if (logLevel !== 'info') {
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
pluginRuntimeConfig: {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
},
|
},
|
||||||
appPath: '/tmp/subminer.app',
|
appPath: '/tmp/subminer.app',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
|||||||
appPath: string,
|
appPath: string,
|
||||||
args: LauncherCommandContext['args'],
|
args: LauncherCommandContext['args'],
|
||||||
runtimePluginPath?: string | null,
|
runtimePluginPath?: string | null,
|
||||||
|
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
|||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
deps: MpvCommandDeps = defaultDeps,
|
deps: MpvCommandDeps = defaultDeps,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||||
if (!args.mpvIdle) {
|
if (!args.mpvIdle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
|
|||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
|
{
|
||||||
|
...pluginRuntimeConfig,
|
||||||
|
backend: args.backend,
|
||||||
|
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
|
|||||||
@@ -72,9 +72,14 @@ function createContext(): LauncherCommandContext {
|
|||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
pluginRuntimeConfig: {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
},
|
},
|
||||||
appPath: '/tmp/SubMiner.AppImage',
|
appPath: '/tmp/SubMiner.AppImage',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
@@ -140,7 +145,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args = {
|
context.args = {
|
||||||
...context.args,
|
...context.args,
|
||||||
@@ -149,9 +154,14 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
|||||||
};
|
};
|
||||||
context.pluginRuntimeConfig = {
|
context.pluginRuntimeConfig = {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
};
|
};
|
||||||
const appPath = context.appPath ?? '';
|
const appPath = context.appPath ?? '';
|
||||||
state.appPath = appPath;
|
state.appPath = appPath;
|
||||||
@@ -164,7 +174,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
|||||||
mpvProc.exitCode = null;
|
mpvProc.exitCode = null;
|
||||||
mpvProc.killed = false;
|
mpvProc.killed = false;
|
||||||
mpvProc.kill = () => true;
|
mpvProc.kill = () => true;
|
||||||
let cleanupSawManagedOverlay = false;
|
let cleanupSawManagedOverlay = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
@@ -190,7 +200,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
|||||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(cleanupSawManagedOverlay, true);
|
assert.equal(cleanupSawManagedOverlay, false);
|
||||||
} finally {
|
} finally {
|
||||||
state.appPath = '';
|
state.appPath = '';
|
||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
|||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
markOverlayManagedByLauncher,
|
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
startMpv,
|
startMpv,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
@@ -238,6 +237,11 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
|
runtimePluginConfig: {
|
||||||
|
...pluginRuntimeConfig,
|
||||||
|
backend: args.backend,
|
||||||
|
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -263,7 +267,6 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
markOverlayManagedByLauncher(appPath);
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
|||||||
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||||
import { readExternalYomitanProfilePath } from './config.js';
|
import { readExternalYomitanProfilePath } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPluginConfigCandidates,
|
buildPluginRuntimeScriptOptParts,
|
||||||
parsePluginRuntimeConfigContent,
|
parsePluginRuntimeConfigFromMainConfig,
|
||||||
} from './config/plugin-runtime-config.js';
|
} from './config/plugin-runtime-config.js';
|
||||||
import { getDefaultSocketPath } from './types.js';
|
import { getDefaultSocketPath } from './types.js';
|
||||||
|
|
||||||
@@ -86,10 +86,24 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
|||||||
mpv: {
|
mpv: {
|
||||||
launchMode: ' maximized ',
|
launchMode: ' maximized ',
|
||||||
executablePath: 'ignored-here',
|
executablePath: 'ignored-here',
|
||||||
|
socketPath: '/tmp/custom.sock',
|
||||||
|
backend: 'x11',
|
||||||
|
autoStartSubMiner: false,
|
||||||
|
pauseUntilOverlayReady: false,
|
||||||
|
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(parsed.launchMode, 'maximized');
|
assert.equal(parsed.launchMode, 'maximized');
|
||||||
|
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||||
|
assert.equal(parsed.backend, 'x11');
|
||||||
|
assert.equal(parsed.autoStartSubMiner, false);
|
||||||
|
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||||
|
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
assert.equal(parsed.aniskipEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||||
@@ -102,39 +116,102 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
|||||||
assert.equal(parsed.launchMode, undefined);
|
assert.equal(parsed.launchMode, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||||
# comment
|
auto_start_overlay: false,
|
||||||
socket_path = /tmp/custom.sock # trailing comment
|
texthooker: {
|
||||||
auto_start = yes
|
launchAtStartup: false,
|
||||||
auto_start_visible_overlay = true
|
},
|
||||||
auto_start_pause_until_ready = 1
|
mpv: {
|
||||||
`);
|
socketPath: '/tmp/config.sock',
|
||||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
backend: 'sway',
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.socketPath, '/tmp/config.sock');
|
||||||
|
assert.equal(parsed.backend, 'sway');
|
||||||
assert.equal(parsed.autoStart, true);
|
assert.equal(parsed.autoStart, true);
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
|
||||||
auto_start = maybe
|
|
||||||
auto_start_visible_overlay = no
|
|
||||||
auto_start_pause_until_ready = off
|
|
||||||
`);
|
|
||||||
assert.equal(parsed.autoStart, false);
|
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
|
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||||
|
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
|
||||||
|
|
||||||
|
assert.equal(parsed.autoStart, true);
|
||||||
|
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||||
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipEnabled, true);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
getPluginConfigCandidates({
|
buildPluginRuntimeScriptOptParts(
|
||||||
platform: 'win32',
|
{
|
||||||
homeDir: 'C:\\Users\\tester',
|
socketPath: '/tmp/config.sock',
|
||||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
binaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
}),
|
backend: 'x11',
|
||||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: false,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
|
},
|
||||||
|
'/fallback/SubMiner.AppImage',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'subminer-binary_path=/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
'subminer-socket_path=/tmp/config.sock',
|
||||||
|
'subminer-backend=x11',
|
||||||
|
'subminer-auto_start=yes',
|
||||||
|
'subminer-auto_start_visible_overlay=no',
|
||||||
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'subminer-texthooker_enabled=no',
|
||||||
|
'subminer-aniskip_enabled=no',
|
||||||
|
'subminer-aniskip_button_key=F8',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildPluginRuntimeScriptOptParts(
|
||||||
|
{
|
||||||
|
socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes',
|
||||||
|
binaryPath: '/opt/SubMiner,\nSubMiner.AppImage',
|
||||||
|
backend: 'x11',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: false,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8,\nF9',
|
||||||
|
},
|
||||||
|
'/fallback/SubMiner.AppImage',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'subminer-binary_path=/opt/SubMiner SubMiner.AppImage',
|
||||||
|
'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes',
|
||||||
|
'subminer-backend=x11',
|
||||||
|
'subminer-auto_start=yes',
|
||||||
|
'subminer-auto_start_visible_overlay=no',
|
||||||
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'subminer-texthooker_enabled=no',
|
||||||
|
'subminer-aniskip_enabled=no',
|
||||||
|
'subminer-aniskip_button_key=F8 F9',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function createDefaultArgs(
|
|||||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||||
|
|
||||||
const parsed: Args = {
|
const parsed: Args = {
|
||||||
backend: 'auto',
|
backend: mpvConfig.backend ?? 'auto',
|
||||||
directory: '.',
|
directory: '.',
|
||||||
recursive: false,
|
recursive: false,
|
||||||
profile: '',
|
profile: '',
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
||||||
|
import type { Backend } from '../types.js';
|
||||||
import type { LauncherMpvConfig } from '../types.js';
|
import type { LauncherMpvConfig } from '../types.js';
|
||||||
|
|
||||||
|
function parseBackend(value: unknown): Backend | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === 'auto' ||
|
||||||
|
normalized === 'hyprland' ||
|
||||||
|
normalized === 'sway' ||
|
||||||
|
normalized === 'x11' ||
|
||||||
|
normalized === 'macos' ||
|
||||||
|
normalized === 'windows'
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonEmptyString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
||||||
const mpvRaw = root.mpv;
|
const mpvRaw = root.mpv;
|
||||||
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||||
@@ -8,5 +31,15 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||||
|
socketPath: parseNonEmptyString(mpv.socketPath),
|
||||||
|
backend: parseBackend(mpv.backend),
|
||||||
|
autoStartSubMiner:
|
||||||
|
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
|
||||||
|
pauseUntilOverlayReady:
|
||||||
|
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||||
|
subminerBinaryPath:
|
||||||
|
typeof mpv.subminerBinaryPath === 'string' ? mpv.subminerBinaryPath.trim() : undefined,
|
||||||
|
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||||
|
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,76 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { log } from '../log.js';
|
import { log } from '../log.js';
|
||||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
import type { Backend, LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||||
|
import { buildSubminerPluginRuntimeScriptOptParts } from '../../src/shared/subminer-plugin-script-opts.js';
|
||||||
|
import { parseLauncherMpvConfig } from './mpv-config.js';
|
||||||
|
import { readLauncherMainConfigObject } from './shared-config-reader.js';
|
||||||
|
|
||||||
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
function rootObject(root: Record<string, unknown> | null, key: string): Record<string, unknown> {
|
||||||
return platform === 'win32' ? path.win32 : path.posix;
|
const value = root?.[key];
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginConfigCandidates(options?: {
|
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||||
platform?: NodeJS.Platform;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
homeDir?: string;
|
}
|
||||||
xdgConfigHome?: string;
|
|
||||||
appDataDir?: string;
|
|
||||||
}): string[] {
|
|
||||||
const platform = options?.platform ?? process.platform;
|
|
||||||
const homeDir = options?.homeDir ?? os.homedir();
|
|
||||||
const platformPath = getPlatformPath(platform);
|
|
||||||
|
|
||||||
if (platform === 'win32') {
|
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||||
const appDataDir =
|
if (typeof value !== 'string') return fallback;
|
||||||
options?.appDataDir?.trim() ||
|
const trimmed = value.trim();
|
||||||
process.env.APPDATA?.trim() ||
|
return trimmed.length > 0 ? trimmed : fallback;
|
||||||
platformPath.join(homeDir, 'AppData', 'Roaming');
|
}
|
||||||
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
|
||||||
|
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||||
|
if (typeof value !== 'string') return fallback;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === 'auto' ||
|
||||||
|
normalized === 'hyprland' ||
|
||||||
|
normalized === 'sway' ||
|
||||||
|
normalized === 'x11' ||
|
||||||
|
normalized === 'macos' ||
|
||||||
|
normalized === 'windows'
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
return fallback;
|
||||||
const xdgConfigHome =
|
|
||||||
options?.xdgConfigHome?.trim() ||
|
|
||||||
process.env.XDG_CONFIG_HOME ||
|
|
||||||
platformPath.join(homeDir, '.config');
|
|
||||||
return Array.from(
|
|
||||||
new Set([
|
|
||||||
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePluginRuntimeConfigContent(
|
export function parsePluginRuntimeConfigFromMainConfig(
|
||||||
content: string,
|
root: Record<string, unknown> | null,
|
||||||
logLevel: LogLevel = 'warn',
|
|
||||||
): PluginRuntimeConfig {
|
): PluginRuntimeConfig {
|
||||||
const runtimeConfig: PluginRuntimeConfig = {
|
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
const texthooker = rootObject(root, 'texthooker');
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
return {
|
||||||
autoStartPauseUntilReady: true,
|
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
||||||
|
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
||||||
|
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
||||||
|
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||||
|
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||||
|
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||||
|
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||||
|
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||||
|
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const parseBooleanValue = (key: string, value: string): boolean => {
|
export function buildPluginRuntimeScriptOptParts(
|
||||||
const normalized = value.trim().toLowerCase();
|
runtimeConfig: PluginRuntimeConfig,
|
||||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
fallbackAppPath: string,
|
||||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
): string[] {
|
||||||
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath);
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
|
||||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
|
||||||
if (!keyValueMatch) continue;
|
|
||||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
|
||||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
if (key === 'socket_path') {
|
|
||||||
runtimeConfig.socketPath = value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start') {
|
|
||||||
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_visible_overlay') {
|
|
||||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
|
||||||
'auto_start_visible_overlay',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_pause_until_ready') {
|
|
||||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
|
||||||
'auto_start_pause_until_ready',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runtimeConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const candidates = getPluginConfigCandidates();
|
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
|
||||||
const defaults: PluginRuntimeConfig = {
|
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const configPath of candidates) {
|
|
||||||
if (!fs.existsSync(configPath)) continue;
|
|
||||||
try {
|
|
||||||
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
logLevel,
|
|
||||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
|
||||||
);
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||||
);
|
);
|
||||||
return defaults;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-15
@@ -157,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
|
|||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${expectedSocket}\n`,
|
JSON.stringify({ mpv: { socketPath: expectedSocket } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
@@ -175,10 +175,10 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
|||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
const socketPath = path.join(root, 'missing.sock');
|
const socketPath = path.join(root, 'missing.sock');
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\n`,
|
JSON.stringify({ mpv: { socketPath } }),
|
||||||
);
|
);
|
||||||
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
|
|
||||||
@@ -280,6 +280,34 @@ test('launcher config command forwards app configuration window command', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('launcher config command suppresses known Electron macOS menu diagnostics', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
|
||||||
|
'printf "%s\\n" "real stderr line" >&2',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
};
|
||||||
|
const result = runLauncher(['config'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(result.stderr, 'real stderr line\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -293,7 +321,6 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(videoPath, 'fake video content');
|
fs.writeFileSync(videoPath, 'fake video content');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
@@ -308,8 +335,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: false,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: false,
|
||||||
|
pauseUntilOverlayReady: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
@@ -373,7 +407,6 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(videoPath, 'fake video content');
|
fs.writeFileSync(videoPath, 'fake video content');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
@@ -388,8 +421,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: true,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
@@ -443,7 +483,6 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -457,8 +496,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: true,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
|
|||||||
@@ -114,6 +114,36 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => {
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
|
const { dir } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'SubMiner.AppImage');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
'printf "args:%s\\n" "$*"',
|
||||||
|
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
||||||
|
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
||||||
|
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.match(result.stdout, /^args:\n/m);
|
||||||
|
assert.match(result.stdout, /^argc:2\n/m);
|
||||||
|
assert.match(result.stdout, /^arg0:--app-ping\n/m);
|
||||||
|
assert.match(result.stdout, /^arg1:--socket\n/m);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('parseMpvArgString preserves empty quoted tokens', () => {
|
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||||
'--title',
|
'--title',
|
||||||
|
|||||||
+86
-22
@@ -8,10 +8,11 @@ import {
|
|||||||
detectInstalledMpvPlugin,
|
detectInstalledMpvPlugin,
|
||||||
type InstalledMpvPluginDetection,
|
type InstalledMpvPluginDetection,
|
||||||
} from '../src/main/runtime/first-run-setup-plugin.js';
|
} from '../src/main/runtime/first-run-setup-plugin.js';
|
||||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
|
||||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
|
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||||
import { nowMs } from './time.js';
|
import { nowMs } from './time.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
@@ -38,6 +39,7 @@ export const state = {
|
|||||||
type SpawnTarget = {
|
type SpawnTarget = {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||||
@@ -45,6 +47,8 @@ type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | '
|
|||||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||||
|
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||||
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
|
|
||||||
export interface LauncherRuntimePluginPlan {
|
export interface LauncherRuntimePluginPlan {
|
||||||
scriptPath: string | null;
|
scriptPath: string | null;
|
||||||
@@ -849,6 +853,7 @@ export async function startMpv(
|
|||||||
startPaused?: boolean;
|
startPaused?: boolean;
|
||||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||||
runtimePluginPath?: string | null;
|
runtimePluginPath?: string | null;
|
||||||
|
runtimePluginConfig?: PluginRuntimeConfig;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||||
@@ -916,13 +921,13 @@ export async function startMpv(
|
|||||||
options?.disableYoutubeSubtitleAutoLoad === true
|
options?.disableYoutubeSubtitleAutoLoad === true
|
||||||
? ['subminer-auto_start_pause_until_ready=no']
|
? ['subminer-auto_start_pause_until_ready=no']
|
||||||
: [];
|
: [];
|
||||||
const scriptOpts = buildSubminerScriptOpts(
|
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||||
appPath,
|
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||||
socketPath,
|
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
aniSkipMetadata,
|
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
||||||
args.logLevel,
|
...runtimeScriptOpts,
|
||||||
extraScriptOpts,
|
...extraScriptOpts,
|
||||||
);
|
]);
|
||||||
if (aniSkipMetadata) {
|
if (aniSkipMetadata) {
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
@@ -1007,7 +1012,7 @@ export async function startOverlay(
|
|||||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||||
state.overlayProc = spawn(target.command, target.args, {
|
state.overlayProc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(state.overlayProc);
|
attachAppProcessLogging(state.overlayProc);
|
||||||
markOverlayManagedByLauncher(appPath);
|
markOverlayManagedByLauncher(appPath);
|
||||||
@@ -1144,7 +1149,7 @@ function stopManagedOverlayApp(args: Args): void {
|
|||||||
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||||
const result = spawnSync(target.command, target.args, {
|
const result = spawnSync(target.command, target.args, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
||||||
@@ -1161,13 +1166,40 @@ function stopManagedOverlayApp(args: Args): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
function clearTransportedAppArgs(env: Record<string, string | undefined>): void {
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
if (key === TRANSPORTED_APP_ARGC_ENV || /^SUBMINER_APP_ARG_\d+$/.test(key)) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransportedAppArgsEnv(appArgs: string[]): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
[TRANSPORTED_APP_ARGC_ENV]: String(appArgs.length),
|
||||||
|
};
|
||||||
|
appArgs.forEach((arg, index) => {
|
||||||
|
env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`] = arg;
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldTransportAppArgsForAppImage(appPath: string): boolean {
|
||||||
|
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAppEnv(
|
||||||
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
extraEnv: NodeJS.ProcessEnv = {},
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
const env: Record<string, string | undefined> = {
|
const env: Record<string, string | undefined> = {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
SUBMINER_APP_LOG: getAppLogPath(),
|
SUBMINER_APP_LOG: getAppLogPath(),
|
||||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||||
};
|
};
|
||||||
delete env.ELECTRON_RUN_AS_NODE;
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
|
clearTransportedAppArgs(env);
|
||||||
|
Object.assign(env, extraEnv);
|
||||||
const layers = env.VK_INSTANCE_LAYERS;
|
const layers = env.VK_INSTANCE_LAYERS;
|
||||||
if (typeof layers === 'string' && layers.trim().length > 0) {
|
if (typeof layers === 'string' && layers.trim().length > 0) {
|
||||||
const filtered = layers
|
const filtered = layers
|
||||||
@@ -1229,6 +1261,14 @@ function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KNOWN_ELECTRON_MENU_DIAGNOSTIC =
|
||||||
|
'representedObject is not a WeakPtrToElectronMenuModelAsNSObject';
|
||||||
|
|
||||||
|
function filterKnownElectronDiagnostics(chunk: string): string {
|
||||||
|
const lines = chunk.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
||||||
|
return lines.filter((line) => !line.includes(KNOWN_ELECTRON_MENU_DIAGNOSTIC)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function attachAppProcessLogging(
|
function attachAppProcessLogging(
|
||||||
proc: ReturnType<typeof spawn>,
|
proc: ReturnType<typeof spawn>,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -1243,8 +1283,12 @@ function attachAppProcessLogging(
|
|||||||
if (options?.mirrorStdout) process.stdout.write(chunk);
|
if (options?.mirrorStdout) process.stdout.write(chunk);
|
||||||
});
|
});
|
||||||
proc.stderr?.on('data', (chunk: string) => {
|
proc.stderr?.on('data', (chunk: string) => {
|
||||||
appendCapturedAppOutput('STDERR', chunk);
|
const filteredChunk = filterKnownElectronDiagnostics(chunk);
|
||||||
if (options?.mirrorStderr) process.stderr.write(chunk);
|
if (!filteredChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendCapturedAppOutput('STDERR', filteredChunk);
|
||||||
|
if (options?.mirrorStderr) process.stderr.write(filteredChunk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,7 +1304,7 @@ function runSyncAppCommand(
|
|||||||
} {
|
} {
|
||||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
const result = spawnSync(target.command, target.args, {
|
const result = spawnSync(target.command, target.args, {
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
if (result.stdout) {
|
if (result.stdout) {
|
||||||
@@ -1268,13 +1312,16 @@ function runSyncAppCommand(
|
|||||||
if (mirrorOutput) process.stdout.write(result.stdout);
|
if (mirrorOutput) process.stdout.write(result.stdout);
|
||||||
}
|
}
|
||||||
if (result.stderr) {
|
if (result.stderr) {
|
||||||
appendCapturedAppOutput('STDERR', result.stderr);
|
const filteredStderr = filterKnownElectronDiagnostics(result.stderr);
|
||||||
if (mirrorOutput) process.stderr.write(result.stderr);
|
if (filteredStderr) {
|
||||||
|
appendCapturedAppOutput('STDERR', filteredStderr);
|
||||||
|
if (mirrorOutput) process.stderr.write(filteredStderr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: result.status ?? 1,
|
status: result.status ?? 1,
|
||||||
stdout: result.stdout ?? '',
|
stdout: result.stdout ?? '',
|
||||||
stderr: result.stderr ?? '',
|
stderr: result.stderr ? filterKnownElectronDiagnostics(result.stderr) : '',
|
||||||
error: result.error ?? undefined,
|
error: result.error ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1337,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
||||||
|
if (shouldTransportAppArgsForAppImage(appPath)) {
|
||||||
|
return {
|
||||||
|
command: appPath,
|
||||||
|
args: [],
|
||||||
|
env: buildTransportedAppArgsEnv(appArgs),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return { command: appPath, args: appArgs };
|
return { command: appPath, args: appArgs };
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1358,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
|
|||||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
@@ -1323,7 +1377,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void
|
|||||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(proc);
|
attachAppProcessLogging(proc);
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
@@ -1374,7 +1428,7 @@ export function runAppCommandAttached(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
@@ -1445,7 +1499,7 @@ export function launchAppCommandDetached(
|
|||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', stdoutFd, stderrFd],
|
stdio: ['ignore', stdoutFd, stderrFd],
|
||||||
detached: true,
|
detached: true,
|
||||||
env: buildAppEnv(),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||||
@@ -1462,6 +1516,7 @@ export function launchMpvIdleDetached(
|
|||||||
appPath: string,
|
appPath: string,
|
||||||
args: Args,
|
args: Args,
|
||||||
runtimePluginPath?: string | null,
|
runtimePluginPath?: string | null,
|
||||||
|
runtimePluginConfig?: PluginRuntimeConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return (async () => {
|
return (async () => {
|
||||||
await terminateTrackedDetachedMpv(args.logLevel);
|
await terminateTrackedDetachedMpv(args.logLevel);
|
||||||
@@ -1483,8 +1538,17 @@ export function launchMpvIdleDetached(
|
|||||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||||
}
|
}
|
||||||
mpvArgs.push('--idle=yes');
|
mpvArgs.push('--idle=yes');
|
||||||
|
const runtimeScriptOpts = runtimePluginConfig
|
||||||
|
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||||
|
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||||
mpvArgs.push(
|
mpvArgs.push(
|
||||||
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
|
`--script-opts=${buildSubminerScriptOpts(
|
||||||
|
appPath,
|
||||||
|
socketPath,
|
||||||
|
null,
|
||||||
|
args.logLevel,
|
||||||
|
runtimeScriptOpts,
|
||||||
|
)}`,
|
||||||
);
|
);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
|
|||||||
+11
-13
@@ -58,14 +58,11 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
|
|
||||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(videoPath, 'fake video fixture');
|
fs.writeFileSync(videoPath, 'fake video fixture');
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
`socket_path=${socketPath}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
|
||||||
const setupState = createDefaultSetupState();
|
const setupState = createDefaultSetupState();
|
||||||
setupState.status = 'completed';
|
setupState.status = 'completed';
|
||||||
setupState.completedAt = '2026-03-07T00:00:00.000Z';
|
setupState.completedAt = '2026-03-07T00:00:00.000Z';
|
||||||
@@ -356,14 +353,15 @@ test(
|
|||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(getDefaultConfigDir(smokeCase), 'config.jsonc'),
|
||||||
[
|
JSON.stringify({
|
||||||
`socket_path=${smokeCase.socketPath}`,
|
auto_start_overlay: true,
|
||||||
'auto_start=yes',
|
mpv: {
|
||||||
'auto_start_visible_overlay=yes',
|
socketPath: smokeCase.socketPath,
|
||||||
'auto_start_pause_until_ready=yes',
|
autoStartSubMiner: true,
|
||||||
'',
|
pauseUntilOverlayReady: true,
|
||||||
].join('\n'),
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const env = makeTestEnv(smokeCase);
|
const env = makeTestEnv(smokeCase);
|
||||||
|
|||||||
+14
-5
@@ -1,15 +1,12 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import type { MpvLaunchMode } from '../src/types/config.js';
|
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||||
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||||
|
|
||||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||||
if (platform === 'win32') {
|
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
|
||||||
return '\\\\.\\pipe\\subminer-socket';
|
|
||||||
}
|
|
||||||
return '/tmp/subminer-socket';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||||
@@ -178,13 +175,25 @@ export interface LauncherJellyfinConfig {
|
|||||||
|
|
||||||
export interface LauncherMpvConfig {
|
export interface LauncherMpvConfig {
|
||||||
launchMode?: MpvLaunchMode;
|
launchMode?: MpvLaunchMode;
|
||||||
|
socketPath?: string;
|
||||||
|
backend?: MpvBackend;
|
||||||
|
autoStartSubMiner?: boolean;
|
||||||
|
pauseUntilOverlayReady?: boolean;
|
||||||
|
subminerBinaryPath?: string;
|
||||||
|
aniskipEnabled?: boolean;
|
||||||
|
aniskipButtonKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginRuntimeConfig {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
|
binaryPath: string;
|
||||||
|
backend: Backend;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: boolean;
|
||||||
|
texthookerEnabled: boolean;
|
||||||
|
aniskipEnabled: boolean;
|
||||||
|
aniskipButtonKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecOptions {
|
export interface CommandExecOptions {
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const windows_helper_1 = require("./window-trackers/windows-helper");
|
|||||||
const args_1 = require("./cli/args");
|
const args_1 = require("./cli/args");
|
||||||
const help_1 = require("./cli/help");
|
const help_1 = require("./cli/help");
|
||||||
const contracts_1 = require("./shared/ipc/contracts");
|
const contracts_1 = require("./shared/ipc/contracts");
|
||||||
|
const anki_connect_1 = require("./anki-connect");
|
||||||
const startup_mode_flags_1 = require("./main/runtime/startup-mode-flags");
|
const startup_mode_flags_1 = require("./main/runtime/startup-mode-flags");
|
||||||
const config_validation_1 = require("./main/config-validation");
|
const config_validation_1 = require("./main/config-validation");
|
||||||
const anilist_1 = require("./main/runtime/domains/anilist");
|
const anilist_1 = require("./main/runtime/domains/anilist");
|
||||||
@@ -197,10 +198,13 @@ const character_dictionary_auto_sync_1 = require("./main/runtime/character-dicti
|
|||||||
const character_dictionary_auto_sync_completion_1 = require("./main/runtime/character-dictionary-auto-sync-completion");
|
const character_dictionary_auto_sync_completion_1 = require("./main/runtime/character-dictionary-auto-sync-completion");
|
||||||
const character_dictionary_auto_sync_notifications_1 = require("./main/runtime/character-dictionary-auto-sync-notifications");
|
const character_dictionary_auto_sync_notifications_1 = require("./main/runtime/character-dictionary-auto-sync-notifications");
|
||||||
const current_media_tokenization_gate_1 = require("./main/runtime/current-media-tokenization-gate");
|
const current_media_tokenization_gate_1 = require("./main/runtime/current-media-tokenization-gate");
|
||||||
|
const current_subtitle_snapshot_1 = require("./main/runtime/current-subtitle-snapshot");
|
||||||
const startup_osd_sequencer_1 = require("./main/runtime/startup-osd-sequencer");
|
const startup_osd_sequencer_1 = require("./main/runtime/startup-osd-sequencer");
|
||||||
const app_updater_1 = require("./main/runtime/update/app-updater");
|
const app_updater_1 = require("./main/runtime/update/app-updater");
|
||||||
const fetch_adapter_1 = require("./main/runtime/update/fetch-adapter");
|
const fetch_adapter_1 = require("./main/runtime/update/fetch-adapter");
|
||||||
|
const curl_http_executor_1 = require("./main/runtime/update/curl-http-executor");
|
||||||
const release_assets_1 = require("./main/runtime/update/release-assets");
|
const release_assets_1 = require("./main/runtime/update/release-assets");
|
||||||
|
const release_metadata_policy_1 = require("./main/runtime/update/release-metadata-policy");
|
||||||
const launcher_updater_1 = require("./main/runtime/update/launcher-updater");
|
const launcher_updater_1 = require("./main/runtime/update/launcher-updater");
|
||||||
const update_notifications_1 = require("./main/runtime/update/update-notifications");
|
const update_notifications_1 = require("./main/runtime/update/update-notifications");
|
||||||
const update_dialogs_1 = require("./main/runtime/update/update-dialogs");
|
const update_dialogs_1 = require("./main/runtime/update/update-dialogs");
|
||||||
@@ -209,8 +213,7 @@ const update_service_1 = require("./main/runtime/update/update-service");
|
|||||||
const support_assets_1 = require("./main/runtime/update/support-assets");
|
const support_assets_1 = require("./main/runtime/update/support-assets");
|
||||||
const subtitle_prefetch_runtime_1 = require("./main/runtime/subtitle-prefetch-runtime");
|
const subtitle_prefetch_runtime_1 = require("./main/runtime/subtitle-prefetch-runtime");
|
||||||
const setup_window_factory_1 = require("./main/runtime/setup-window-factory");
|
const setup_window_factory_1 = require("./main/runtime/setup-window-factory");
|
||||||
const config_settings_window_1 = require("./main/runtime/config-settings-window");
|
const config_settings_runtime_1 = require("./main/runtime/config-settings-runtime");
|
||||||
const config_settings_save_1 = require("./main/runtime/config-settings-save");
|
|
||||||
const youtube_playback_1 = require("./main/runtime/youtube-playback");
|
const youtube_playback_1 = require("./main/runtime/youtube-playback");
|
||||||
const yomitan_profile_policy_1 = require("./main/runtime/yomitan-profile-policy");
|
const yomitan_profile_policy_1 = require("./main/runtime/yomitan-profile-policy");
|
||||||
const yomitan_read_only_log_1 = require("./main/runtime/yomitan-read-only-log");
|
const yomitan_read_only_log_1 = require("./main/runtime/yomitan-read-only-log");
|
||||||
@@ -219,7 +222,6 @@ const state_1 = require("./main/state");
|
|||||||
const anilist_url_guard_1 = require("./main/anilist-url-guard");
|
const anilist_url_guard_1 = require("./main/anilist-url-guard");
|
||||||
const config_2 = require("./config");
|
const config_2 = require("./config");
|
||||||
const path_resolution_1 = require("./config/path-resolution");
|
const path_resolution_1 = require("./config/path-resolution");
|
||||||
const jsonc_edit_1 = require("./config/settings/jsonc-edit");
|
|
||||||
const registry_2 = require("./config/settings/registry");
|
const registry_2 = require("./config/settings/registry");
|
||||||
const subtitle_cue_parser_1 = require("./core/services/subtitle-cue-parser");
|
const subtitle_cue_parser_1 = require("./core/services/subtitle-cue-parser");
|
||||||
const subtitle_prefetch_1 = require("./core/services/subtitle-prefetch");
|
const subtitle_prefetch_1 = require("./core/services/subtitle-prefetch");
|
||||||
@@ -293,16 +295,17 @@ const texthookerService = new services_1.Texthooker(() => {
|
|||||||
const config = getResolvedConfig();
|
const config = getResolvedConfig();
|
||||||
const characterDictionaryEnabled = config.anilist.characterDictionary.enabled &&
|
const characterDictionaryEnabled = config.anilist.characterDictionary.enabled &&
|
||||||
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
||||||
const knownAndNPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled);
|
const knownWordColoringEnabled = getRuntimeBooleanOption('subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled);
|
||||||
|
const nPlusOneColoringEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled);
|
||||||
return {
|
return {
|
||||||
enableKnownWordColoring: knownAndNPlusOneEnabled,
|
enableKnownWordColoring: knownWordColoringEnabled,
|
||||||
enableNPlusOneColoring: knownAndNPlusOneEnabled,
|
enableNPlusOneColoring: nPlusOneColoringEnabled,
|
||||||
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
||||||
enableFrequencyColoring: getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled),
|
enableFrequencyColoring: getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled),
|
||||||
enableJlptColoring: getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt),
|
enableJlptColoring: getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt),
|
||||||
characterDictionaryEnabled,
|
characterDictionaryEnabled,
|
||||||
knownWordColor: config.ankiConnect.knownWords.color,
|
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||||
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
||||||
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
||||||
@@ -732,6 +735,16 @@ const youtubePlaybackRuntime = (0, youtube_playback_runtime_1.createYoutubePlayb
|
|||||||
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
|
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
|
||||||
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
|
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
|
||||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
||||||
|
}, {
|
||||||
|
socketPath: appState.mpvSocketPath,
|
||||||
|
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
|
||||||
|
backend: getResolvedConfig().mpv.backend,
|
||||||
|
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
|
||||||
|
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
|
||||||
|
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
|
||||||
|
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
|
||||||
|
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||||
|
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||||
}),
|
}),
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
@@ -756,12 +769,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
|||||||
resourcesPath: process.resourcesPath,
|
resourcesPath: process.resourcesPath,
|
||||||
appExePath: process.execPath,
|
appExePath: process.execPath,
|
||||||
});
|
});
|
||||||
(0, first_run_setup_plugin_1.syncInstalledFirstRunPluginBinaryPath)({
|
|
||||||
platform: process.platform,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
binaryPath: process.execPath,
|
|
||||||
});
|
|
||||||
const firstRunSetupService = (0, first_run_setup_service_1.createFirstRunSetupService)({
|
const firstRunSetupService = (0, first_run_setup_service_1.createFirstRunSetupService)({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
@@ -1233,80 +1240,15 @@ const buildConfigHotReloadRuntimeMainDepsHandler = (0, overlay_1.createBuildConf
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const configHotReloadRuntime = (0, services_1.createConfigHotReloadRuntime)(buildConfigHotReloadRuntimeMainDepsHandler());
|
const configHotReloadRuntime = (0, services_1.createConfigHotReloadRuntime)(buildConfigHotReloadRuntimeMainDepsHandler());
|
||||||
function getConfigSettingsSnapshot() {
|
const configSettingsRuntime = (0, config_settings_runtime_1.createConfigSettingsRuntime)({
|
||||||
return (0, jsonc_edit_1.buildConfigSettingsSnapshot)({
|
fields: configSettingsFields,
|
||||||
configPath: configService.getConfigPath(),
|
|
||||||
rawConfig: configService.getRawConfig(),
|
|
||||||
resolvedConfig: configService.getConfig(),
|
|
||||||
warnings: configService.getWarnings(),
|
|
||||||
fields: configSettingsFields,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function isConfigSettingsPatch(value) {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const operations = value.operations;
|
|
||||||
return (Array.isArray(operations) &&
|
|
||||||
operations.every((operation) => {
|
|
||||||
if (!operation || typeof operation !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const candidate = operation;
|
|
||||||
return ((candidate.op === 'set' || candidate.op === 'reset') &&
|
|
||||||
typeof candidate.path === 'string' &&
|
|
||||||
configSettingsFields.some((field) => field.configPath === candidate.path));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
function writeTextFileAtomically(targetPath, content) {
|
|
||||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
||||||
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(tempPath, content, 'utf-8');
|
|
||||||
fs.renameSync(tempPath, targetPath);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tempPath, { force: true });
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Best effort cleanup after a failed atomic write.
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getRestartRequiredSettingsSections(restartRequiredFields) {
|
|
||||||
const sections = new Set();
|
|
||||||
for (const field of configSettingsFields) {
|
|
||||||
if (restartRequiredFields.some((restartField) => field.configPath === restartField ||
|
|
||||||
field.configPath.startsWith(`${restartField}.`) ||
|
|
||||||
restartField.startsWith(`${field.configPath}.`))) {
|
|
||||||
sections.add(field.section);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...sections].sort();
|
|
||||||
}
|
|
||||||
const saveConfigSettingsPatch = (0, config_settings_save_1.createSaveConfigSettingsPatchHandler)({
|
|
||||||
getConfigPath: () => configService.getConfigPath(),
|
getConfigPath: () => configService.getConfigPath(),
|
||||||
getCurrentConfig: () => configService.getConfig(),
|
getRawConfig: () => configService.getRawConfig(),
|
||||||
|
getConfig: () => configService.getConfig(),
|
||||||
getWarnings: () => configService.getWarnings(),
|
getWarnings: () => configService.getWarnings(),
|
||||||
getSnapshot: () => getConfigSettingsSnapshot(),
|
|
||||||
fileExists: (targetPath) => fs.existsSync(targetPath),
|
|
||||||
readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'),
|
|
||||||
writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content),
|
|
||||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||||
classifyDiff: (previous, next) => (0, services_1.classifyConfigHotReloadDiff)(previous, next),
|
defaultAnkiConnectUrl: config_2.DEFAULT_CONFIG.ankiConnect.url,
|
||||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
createAnkiClient: (url) => new anki_connect_1.AnkiConnectClient(url),
|
||||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(fields),
|
|
||||||
});
|
|
||||||
function ensureConfigSettingsFileExists() {
|
|
||||||
const configPath = configService.getConfigPath();
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
writeTextFileAtomically(configPath, '{}\n');
|
|
||||||
}
|
|
||||||
return configPath;
|
|
||||||
}
|
|
||||||
const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSettingsWindowHandler)({
|
|
||||||
getSettingsWindow: () => appState.configSettingsWindow,
|
getSettingsWindow: () => appState.configSettingsWindow,
|
||||||
setSettingsWindow: (window) => {
|
setSettingsWindow: (window) => {
|
||||||
appState.configSettingsWindow = window;
|
appState.configSettingsWindow = window;
|
||||||
@@ -1316,26 +1258,13 @@ const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSe
|
|||||||
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
||||||
}),
|
}),
|
||||||
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
||||||
|
openPath: (targetPath) => electron_1.shell.openPath(targetPath),
|
||||||
|
ipcMain: electron_1.ipcMain,
|
||||||
|
ipcChannels: contracts_1.IPC_CHANNELS.request,
|
||||||
|
log: (message) => logger.error(message),
|
||||||
});
|
});
|
||||||
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.getConfigSettingsSnapshot, () => getConfigSettingsSnapshot());
|
configSettingsRuntime.registerHandlers();
|
||||||
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.saveConfigSettingsPatch, (_event, patch) => {
|
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
|
||||||
if (!isConfigSettingsPatch(patch)) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
warnings: [],
|
|
||||||
error: 'Invalid config settings patch.',
|
|
||||||
hotReloadFields: [],
|
|
||||||
restartRequiredFields: [],
|
|
||||||
restartRequiredSections: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return saveConfigSettingsPatch(patch);
|
|
||||||
});
|
|
||||||
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsFile, async () => {
|
|
||||||
const openError = await electron_1.shell.openPath(ensureConfigSettingsFileExists());
|
|
||||||
return openError.length === 0;
|
|
||||||
});
|
|
||||||
electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.openConfigSettingsWindow, () => openConfigSettingsWindow());
|
|
||||||
const buildDictionaryRootsHandler = (0, startup_1.createBuildDictionaryRootsMainHandler)({
|
const buildDictionaryRootsHandler = (0, startup_1.createBuildDictionaryRootsMainHandler)({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
dirname: __dirname,
|
dirname: __dirname,
|
||||||
@@ -1891,10 +1820,11 @@ function getRuntimeBooleanOption(id, fallback) {
|
|||||||
}
|
}
|
||||||
function shouldInitializeMecabForAnnotations() {
|
function shouldInitializeMecabForAnnotations() {
|
||||||
const config = getResolvedConfig();
|
const config = getResolvedConfig();
|
||||||
const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled);
|
const knownWordsEnabled = getRuntimeBooleanOption('subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled);
|
||||||
|
const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled);
|
||||||
const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt);
|
const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt);
|
||||||
const frequencyEnabled = getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled);
|
const frequencyEnabled = getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled);
|
||||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||||
}
|
}
|
||||||
const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, } = (0, composers_1.composeJellyfinRuntimeHandlers)({
|
const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, } = (0, composers_1.composeJellyfinRuntimeHandlers)({
|
||||||
getResolvedJellyfinConfigMainDeps: {
|
getResolvedJellyfinConfigMainDeps: {
|
||||||
@@ -1916,6 +1846,17 @@ const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinR
|
|||||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
|
getPluginRuntimeConfig: () => ({
|
||||||
|
socketPath: appState.mpvSocketPath,
|
||||||
|
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
|
||||||
|
backend: getResolvedConfig().mpv.backend,
|
||||||
|
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
|
||||||
|
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
|
||||||
|
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
|
||||||
|
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
|
||||||
|
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||||
|
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||||
|
}),
|
||||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||||
removeSocketPath: (socketPath) => {
|
removeSocketPath: (socketPath) => {
|
||||||
@@ -3114,6 +3055,17 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd
|
|||||||
reportJellyfinRemoteStopped: () => {
|
reportJellyfinRemoteStopped: () => {
|
||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
},
|
},
|
||||||
|
onMpvConnected: () => {
|
||||||
|
if (appState.sessionBindingsInitialized) {
|
||||||
|
(0, services_1.sendMpvCommandRuntime)(appState.mpvClient, [
|
||||||
|
'script-message',
|
||||||
|
'subminer-reload-session-bindings',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (appState.currentSubText.trim()) {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
}
|
||||||
|
},
|
||||||
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||||
recordAnilistMediaDuration: (durationSec) => {
|
recordAnilistMediaDuration: (durationSec) => {
|
||||||
recordAnilistMediaDuration(durationSec);
|
recordAnilistMediaDuration(durationSec);
|
||||||
@@ -3272,7 +3224,7 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd
|
|||||||
},
|
},
|
||||||
getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ??
|
getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||||
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||||
getNPlusOneEnabled: () => getRuntimeBooleanOption('subtitle.annotation.nPlusOne', getResolvedConfig().ankiConnect.knownWords.highlightEnabled),
|
getNPlusOneEnabled: () => getRuntimeBooleanOption('subtitle.annotation.nPlusOne', getResolvedConfig().ankiConnect.nPlusOne.enabled),
|
||||||
getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
getMinSentenceWordsForNPlusOne: () => getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||||
getJlptLevel: (text) => appState.jlptLevelLookup(text),
|
getJlptLevel: (text) => appState.jlptLevelLookup(text),
|
||||||
getJlptEnabled: () => getRuntimeBooleanOption('subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt),
|
getJlptEnabled: () => getRuntimeBooleanOption('subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt),
|
||||||
@@ -3698,6 +3650,8 @@ function getUpdateService() {
|
|||||||
isPackaged: electron_1.app.isPackaged,
|
isPackaged: electron_1.app.isPackaged,
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
getChannel: () => getResolvedConfig().updates.channel,
|
getChannel: () => getResolvedConfig().updates.channel,
|
||||||
|
configureHttpExecutor: process.platform === 'darwin' ? () => (0, curl_http_executor_1.createCurlHttpExecutor)() : undefined,
|
||||||
|
disableDifferentialDownload: process.platform === 'darwin',
|
||||||
isNativeUpdaterSupported: () => (0, app_updater_1.isNativeUpdaterSupported)({
|
isNativeUpdaterSupported: () => (0, app_updater_1.isNativeUpdaterSupported)({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
isPackaged: electron_1.app.isPackaged,
|
isPackaged: electron_1.app.isPackaged,
|
||||||
@@ -3718,7 +3672,7 @@ function getUpdateService() {
|
|||||||
readState: () => updateStateStore.readState(),
|
readState: () => updateStateStore.readState(),
|
||||||
writeState: (state) => updateStateStore.writeState(state),
|
writeState: (state) => updateStateStore.writeState(state),
|
||||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||||
shouldFetchReleaseMetadata: () => process.platform !== 'darwin',
|
shouldFetchReleaseMetadata: ({ appUpdate }) => (0, release_metadata_policy_1.shouldFetchReleaseMetadataForPlatform)(process.platform, appUpdate),
|
||||||
fetchLatestStableRelease: (channel) => (0, release_assets_1.fetchLatestStableRelease)({ fetch: getFetchForUpdater(), channel }),
|
fetchLatestStableRelease: (channel) => (0, release_assets_1.fetchLatestStableRelease)({ fetch: getFetchForUpdater(), channel }),
|
||||||
updateLauncher: (launcherPath, channel, release) => updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
updateLauncher: (launcherPath, channel, release) => updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||||
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||||
@@ -4083,7 +4037,11 @@ const { registerIpcRuntimeHandlers } = (0, composers_1.composeIpcRuntimeHandlers
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
tokenizeCurrentSubtitle: async () => (0, current_subtitle_snapshot_1.resolveCurrentSubtitleForRenderer)({
|
||||||
|
currentSubText: appState.currentSubText,
|
||||||
|
currentSubtitleData: appState.currentSubtitleData,
|
||||||
|
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||||
|
}),
|
||||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
getSubtitleSidebarSnapshot: async () => {
|
getSubtitleSidebarSnapshot: async () => {
|
||||||
@@ -4387,7 +4345,7 @@ const { runAndApplyStartupState } = (0, composers_1.composeHeadlessStartupHandle
|
|||||||
(0, utils_2.enforceUnsupportedWaylandMode)(args);
|
(0, utils_2.enforceUnsupportedWaylandMode)(args);
|
||||||
},
|
},
|
||||||
shouldStartApp: (args) => (0, args_1.shouldStartApp)(args),
|
shouldStartApp: (args) => (0, args_1.shouldStartApp)(args),
|
||||||
getDefaultSocketPath: () => getDefaultSocketPath(),
|
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
|
||||||
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
defaultConfig: config_2.DEFAULT_CONFIG,
|
defaultConfig: config_2.DEFAULT_CONFIG,
|
||||||
@@ -4441,7 +4399,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
onWindowContentReady: () => {
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
if (appState.currentSubText.trim()) {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
}
|
||||||
|
},
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
|
|||||||
+3
-75
@@ -1,75 +1,3 @@
|
|||||||
# SubMiner configuration
|
# SubMiner managed playback config lives in SubMiner config.jsonc.
|
||||||
# Place this file in ~/.config/mpv/script-opts/
|
# This file is intentionally empty so installed/default mpv script-opts do not
|
||||||
|
# override the app config modal or generated config file.
|
||||||
# Path to SubMiner binary (leave empty for auto-detection)
|
|
||||||
# Auto-detection searches common locations, including:
|
|
||||||
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
|
|
||||||
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
|
|
||||||
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/local/bin/subminer, /usr/bin/SubMiner, /usr/bin/subminer
|
|
||||||
binary_path=
|
|
||||||
|
|
||||||
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
|
|
||||||
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
|
|
||||||
socket_path=/tmp/subminer-socket
|
|
||||||
|
|
||||||
# Enable texthooker WebSocket server
|
|
||||||
texthooker_enabled=yes
|
|
||||||
|
|
||||||
# Texthooker WebSocket port
|
|
||||||
texthooker_port=5174
|
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11, macos, windows
|
|
||||||
# "auto" detects based on environment variables
|
|
||||||
backend=auto
|
|
||||||
|
|
||||||
# Automatically start overlay when a file is loaded
|
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
|
||||||
auto_start=yes
|
|
||||||
|
|
||||||
# Automatically show visible overlay when overlay starts
|
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
|
||||||
auto_start_visible_overlay=yes
|
|
||||||
|
|
||||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
|
||||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
|
||||||
auto_start_pause_until_ready=yes
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status
|
|
||||||
osd_messages=yes
|
|
||||||
|
|
||||||
# Log level for plugin and SubMiner binary: debug, info, warn, error
|
|
||||||
log_level=info
|
|
||||||
|
|
||||||
# Enable AniSkip intro detection + markers.
|
|
||||||
aniskip_enabled=yes
|
|
||||||
|
|
||||||
# Force title (optional). Launcher fills this from guessit when available.
|
|
||||||
aniskip_title=
|
|
||||||
|
|
||||||
# Force season (optional). Launcher fills this from guessit when available.
|
|
||||||
aniskip_season=
|
|
||||||
|
|
||||||
# Force MAL id (optional). Leave blank for title lookup.
|
|
||||||
aniskip_mal_id=
|
|
||||||
|
|
||||||
# Force episode number (optional). Leave blank for filename/title detection.
|
|
||||||
aniskip_episode=
|
|
||||||
|
|
||||||
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
|
|
||||||
aniskip_payload=
|
|
||||||
|
|
||||||
# Show intro skip OSD button while inside OP range.
|
|
||||||
aniskip_show_button=yes
|
|
||||||
|
|
||||||
# OSD text shown for intro skip action.
|
|
||||||
# `%s` is replaced by keybinding.
|
|
||||||
aniskip_button_text=You can skip by pressing %s
|
|
||||||
|
|
||||||
# Keybinding to execute intro skip when button is visible.
|
|
||||||
aniskip_button_key=TAB
|
|
||||||
|
|
||||||
# OSD hint duration in seconds (shown during first 3s of intro).
|
|
||||||
aniskip_button_duration=3
|
|
||||||
|
|
||||||
# MPV keybindings provided by plugin/subminer/main.lua:
|
|
||||||
# y-s start, y-S stop, y-t toggle visible overlay
|
|
||||||
|
|||||||
@@ -27,16 +27,16 @@ function M.load(options_lib, default_socket_path)
|
|||||||
local opts = {
|
local opts = {
|
||||||
binary_path = "",
|
binary_path = "",
|
||||||
socket_path = default_socket_path,
|
socket_path = default_socket_path,
|
||||||
texthooker_enabled = true,
|
texthooker_enabled = false,
|
||||||
texthooker_port = 5174,
|
texthooker_port = 5174,
|
||||||
backend = "auto",
|
backend = "auto",
|
||||||
auto_start = true,
|
auto_start = false,
|
||||||
auto_start_visible_overlay = true,
|
auto_start_visible_overlay = false,
|
||||||
auto_start_pause_until_ready = true,
|
auto_start_pause_until_ready = true,
|
||||||
auto_start_pause_until_ready_timeout_seconds = 15,
|
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||||
osd_messages = true,
|
osd_messages = true,
|
||||||
log_level = "info",
|
log_level = "info",
|
||||||
aniskip_enabled = true,
|
aniskip_enabled = false,
|
||||||
aniskip_title = "",
|
aniskip_title = "",
|
||||||
aniskip_season = "",
|
aniskip_season = "",
|
||||||
aniskip_mal_id = "",
|
aniskip_mal_id = "",
|
||||||
|
|||||||
+173
-36
@@ -2,12 +2,15 @@ local M = {}
|
|||||||
|
|
||||||
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||||
|
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
|
||||||
|
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
local opts = ctx.opts
|
local opts = ctx.opts
|
||||||
local state = ctx.state
|
local state = ctx.state
|
||||||
local binary = ctx.binary
|
local binary = ctx.binary
|
||||||
@@ -17,6 +20,8 @@ function M.create(ctx)
|
|||||||
local show_osd = ctx.log.show_osd
|
local show_osd = ctx.log.show_osd
|
||||||
local normalize_log_level = ctx.log.normalize_log_level
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
local run_control_command_async
|
local run_control_command_async
|
||||||
|
local APP_ARGC_ENV = "SUBMINER_APP_ARGC"
|
||||||
|
local APP_ARG_PREFIX = "SUBMINER_APP_ARG_"
|
||||||
|
|
||||||
local function resolve_visible_overlay_startup()
|
local function resolve_visible_overlay_startup()
|
||||||
local raw_visible_overlay = opts.auto_start_visible_overlay
|
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||||
@@ -112,10 +117,12 @@ function M.create(ctx)
|
|||||||
local function disarm_auto_play_ready_gate(options)
|
local function disarm_auto_play_ready_gate(options)
|
||||||
local should_resume = options == nil or options.resume_playback ~= false
|
local should_resume = options == nil or options.resume_playback ~= false
|
||||||
local was_armed = state.auto_play_ready_gate_armed
|
local was_armed = state.auto_play_ready_gate_armed
|
||||||
|
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||||
clear_auto_play_ready_timeout()
|
clear_auto_play_ready_timeout()
|
||||||
clear_auto_play_ready_osd_timer()
|
clear_auto_play_ready_osd_timer()
|
||||||
state.auto_play_ready_gate_armed = false
|
state.auto_play_ready_gate_armed = false
|
||||||
if was_armed and should_resume then
|
state.auto_play_ready_should_resume_playback = false
|
||||||
|
if was_armed and should_resume and should_resume_playback then
|
||||||
mp.set_property_native("pause", false)
|
mp.set_property_native("pause", false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -124,17 +131,26 @@ function M.create(ctx)
|
|||||||
if not state.auto_play_ready_gate_armed then
|
if not state.auto_play_ready_gate_armed then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
mp.set_property_native("pause", false)
|
|
||||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
if should_resume_playback then
|
||||||
|
mp.set_property_native("pause", false)
|
||||||
|
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||||
|
else
|
||||||
|
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function arm_auto_play_ready_gate()
|
local function arm_auto_play_ready_gate()
|
||||||
if state.auto_play_ready_gate_armed then
|
local was_armed = state.auto_play_ready_gate_armed
|
||||||
|
if was_armed then
|
||||||
clear_auto_play_ready_timeout()
|
clear_auto_play_ready_timeout()
|
||||||
clear_auto_play_ready_osd_timer()
|
clear_auto_play_ready_osd_timer()
|
||||||
end
|
end
|
||||||
|
if not was_armed then
|
||||||
|
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
|
||||||
|
end
|
||||||
state.auto_play_ready_gate_armed = true
|
state.auto_play_ready_gate_armed = true
|
||||||
mp.set_property_native("pause", true)
|
mp.set_property_native("pause", true)
|
||||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||||
@@ -164,10 +180,15 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function notify_auto_play_ready()
|
local function notify_auto_play_ready()
|
||||||
release_auto_play_ready_gate("tokenization-ready")
|
release_auto_play_ready_gate("tokenization-ready")
|
||||||
if state.suppress_ready_overlay_restore then
|
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
|
||||||
|
state.force_ready_overlay_restore = false
|
||||||
|
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if state.overlay_running and resolve_visible_overlay_startup() then
|
if force_ready_overlay_restore then
|
||||||
|
state.suppress_ready_overlay_restore = false
|
||||||
|
end
|
||||||
|
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then
|
||||||
run_control_command_async("show-visible-overlay", {
|
run_control_command_async("show-visible-overlay", {
|
||||||
socket_path = opts.socket_path,
|
socket_path = opts.socket_path,
|
||||||
})
|
})
|
||||||
@@ -199,7 +220,10 @@ function M.create(ctx)
|
|||||||
table.insert(args, "--socket")
|
table.insert(args, "--socket")
|
||||||
table.insert(args, socket_path)
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
local should_show_visible = resolve_visible_overlay_startup()
|
local should_show_visible = overrides.show_visible_overlay
|
||||||
|
if should_show_visible == nil then
|
||||||
|
should_show_visible = resolve_visible_overlay_startup()
|
||||||
|
end
|
||||||
if should_show_visible then
|
if should_show_visible then
|
||||||
table.insert(args, "--show-visible-overlay")
|
table.insert(args, "--show-visible-overlay")
|
||||||
else
|
else
|
||||||
@@ -215,12 +239,75 @@ function M.create(ctx)
|
|||||||
return args
|
return args
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function is_appimage_binary(path)
|
||||||
|
return environment.is_linux() and type(path) == "string" and path:lower():match("%.appimage$") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function append_transport_env(env, args)
|
||||||
|
local count = math.max(#args - 1, 0)
|
||||||
|
env[#env + 1] = APP_ARGC_ENV .. "=" .. tostring(count)
|
||||||
|
for index = 2, #args do
|
||||||
|
env[#env + 1] = APP_ARG_PREFIX .. tostring(index - 2) .. "=" .. tostring(args[index])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function env_has_name(env, name)
|
||||||
|
local prefix = name .. "="
|
||||||
|
for _, value in ipairs(env) do
|
||||||
|
if type(value) == "string" and value:sub(1, #prefix) == prefix then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function append_default_app_log_env(env)
|
||||||
|
local log_dir = environment.join_path(environment.resolve_subminer_config_dir(), "logs")
|
||||||
|
local date = os.date("%Y-%m-%d")
|
||||||
|
if not env_has_name(env, "SUBMINER_APP_LOG") then
|
||||||
|
env[#env + 1] = "SUBMINER_APP_LOG=" .. environment.join_path(log_dir, "app-" .. date .. ".log")
|
||||||
|
end
|
||||||
|
if not env_has_name(env, "SUBMINER_MPV_LOG") then
|
||||||
|
env[#env + 1] = "SUBMINER_MPV_LOG=" .. environment.join_path(log_dir, "mpv-" .. date .. ".log")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_appimage_subprocess_env(args)
|
||||||
|
local env = {}
|
||||||
|
if utils and type(utils.get_env_list) == "function" then
|
||||||
|
for _, value in ipairs(utils.get_env_list()) do
|
||||||
|
if
|
||||||
|
type(value) == "string"
|
||||||
|
and not value:match("^" .. APP_ARGC_ENV .. "=")
|
||||||
|
and not value:match("^" .. APP_ARG_PREFIX .. "%d+=")
|
||||||
|
then
|
||||||
|
env[#env + 1] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
append_default_app_log_env(env)
|
||||||
|
append_transport_env(env, args)
|
||||||
|
return env
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_subprocess_command(args)
|
||||||
|
if is_appimage_binary(args[1]) then
|
||||||
|
return {
|
||||||
|
args = { args[1] },
|
||||||
|
env = build_appimage_subprocess_env(args),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { args = args }
|
||||||
|
end
|
||||||
|
|
||||||
run_control_command_async = function(action, overrides, callback)
|
run_control_command_async = function(action, overrides, callback)
|
||||||
local args = build_command_args(action, overrides)
|
local args = build_command_args(action, overrides)
|
||||||
|
local command = build_subprocess_command(args)
|
||||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
args = args,
|
args = command.args,
|
||||||
|
env = command.env,
|
||||||
playback_only = false,
|
playback_only = false,
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
@@ -232,11 +319,36 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
|
||||||
|
attempt = attempt or 1
|
||||||
|
run_control_command_async("app-ping", nil, function(_ok, result)
|
||||||
|
local status = result and result.status
|
||||||
|
local is_running = status == 0
|
||||||
|
local is_not_running = status == 1
|
||||||
|
if (expected_running and is_running) or ((not expected_running) and is_not_running) then
|
||||||
|
on_ready()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if attempt >= OVERLAY_RESTART_PING_MAX_ATTEMPTS then
|
||||||
|
subminer_log("warn", "process", "Timed out waiting for SubMiner app to " .. label)
|
||||||
|
if on_timeout then
|
||||||
|
on_timeout()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.add_timeout(OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS, function()
|
||||||
|
wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt + 1)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local function run_binary_command_async(args, callback)
|
local function run_binary_command_async(args, callback)
|
||||||
|
local command = build_subprocess_command(args)
|
||||||
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
|
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
args = args,
|
args = command.args,
|
||||||
|
env = command.env,
|
||||||
playback_only = false,
|
playback_only = false,
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
@@ -347,9 +459,11 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
state.overlay_running = true
|
state.overlay_running = true
|
||||||
|
|
||||||
|
local command = build_subprocess_command(args)
|
||||||
mp.command_native_async({
|
mp.command_native_async({
|
||||||
name = "subprocess",
|
name = "subprocess",
|
||||||
args = args,
|
args = command.args,
|
||||||
|
env = command.env,
|
||||||
playback_only = false,
|
playback_only = false,
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
@@ -501,38 +615,61 @@ function M.create(ctx)
|
|||||||
subminer_log("info", "process", "Restarting overlay...")
|
subminer_log("info", "process", "Restarting overlay...")
|
||||||
show_osd("Restarting...")
|
show_osd("Restarting...")
|
||||||
|
|
||||||
run_control_command_async("stop", nil, function()
|
run_control_command_async("stop", nil, function(ok, result)
|
||||||
|
if not ok then
|
||||||
|
local reason = result and result.stderr or "unknown error"
|
||||||
|
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
|
||||||
|
show_osd("Restart failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
disarm_auto_play_ready_gate()
|
state.suppress_ready_overlay_restore = false
|
||||||
|
state.force_ready_overlay_restore = true
|
||||||
|
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||||
|
|
||||||
local start_args = build_command_args("start")
|
wait_for_app_ping_state(false, "release the single-instance lock", function()
|
||||||
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
local start_args = build_command_args("start", {
|
||||||
|
show_visible_overlay = true,
|
||||||
|
})
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||||
|
|
||||||
state.overlay_running = true
|
state.overlay_running = true
|
||||||
mp.command_native_async({
|
local command = build_subprocess_command(start_args)
|
||||||
name = "subprocess",
|
mp.command_native_async({
|
||||||
args = start_args,
|
name = "subprocess",
|
||||||
playback_only = false,
|
args = command.args,
|
||||||
capture_stdout = true,
|
env = command.env,
|
||||||
capture_stderr = true,
|
playback_only = false,
|
||||||
}, function(success, result, error)
|
capture_stdout = true,
|
||||||
if not success or (result and result.status ~= 0) then
|
capture_stderr = true,
|
||||||
state.overlay_running = false
|
}, function(success, result, error)
|
||||||
subminer_log(
|
if not success or (result and result.status ~= 0) then
|
||||||
"error",
|
state.overlay_running = false
|
||||||
"process",
|
subminer_log(
|
||||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
"error",
|
||||||
)
|
"process",
|
||||||
show_osd("Restart failed")
|
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||||
else
|
)
|
||||||
show_osd("Restarted successfully")
|
show_osd("Restart failed")
|
||||||
|
else
|
||||||
|
wait_for_app_ping_state(true, "own the single-instance lock", function()
|
||||||
|
run_control_command_async("show-visible-overlay")
|
||||||
|
show_osd("Restarted successfully")
|
||||||
|
end, function()
|
||||||
|
run_control_command_async("show-visible-overlay")
|
||||||
|
show_osd("Restarted successfully")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if resolve_texthooker_enabled(nil) then
|
||||||
|
ensure_texthooker_running(function() end)
|
||||||
end
|
end
|
||||||
|
end, function()
|
||||||
|
show_osd("Restart failed")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if resolve_texthooker_enabled(nil) then
|
|
||||||
ensure_texthooker_running(function() end)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ function M.new()
|
|||||||
prompt_shown = false,
|
prompt_shown = false,
|
||||||
},
|
},
|
||||||
auto_play_ready_gate_armed = false,
|
auto_play_ready_gate_armed = false,
|
||||||
|
auto_play_ready_should_resume_playback = false,
|
||||||
auto_play_ready_timeout = nil,
|
auto_play_ready_timeout = nil,
|
||||||
auto_play_ready_osd_timer = nil,
|
auto_play_ready_osd_timer = nil,
|
||||||
suppress_ready_overlay_restore = false,
|
suppress_ready_overlay_restore = false,
|
||||||
|
force_ready_overlay_restore = false,
|
||||||
current_media_identity = nil,
|
current_media_identity = nil,
|
||||||
pending_reload_media_identity = nil,
|
pending_reload_media_identity = nil,
|
||||||
session_binding_generation = 0,
|
session_binding_generation = 0,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ local function run_plugin_scenario(config)
|
|||||||
logs = {},
|
logs = {},
|
||||||
property_sets = {},
|
property_sets = {},
|
||||||
periodic_timers = {},
|
periodic_timers = {},
|
||||||
|
timeouts = {},
|
||||||
}
|
}
|
||||||
|
|
||||||
local function make_mp_stub()
|
local function make_mp_stub()
|
||||||
@@ -40,6 +41,9 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function mp.get_property_native(name)
|
function mp.get_property_native(name)
|
||||||
|
if name == "pause" then
|
||||||
|
return config.paused == true
|
||||||
|
end
|
||||||
if name == "osd-dimensions" then
|
if name == "osd-dimensions" then
|
||||||
return config.osd_dimensions or {
|
return config.osd_dimensions or {
|
||||||
w = 1280,
|
w = 1280,
|
||||||
@@ -108,11 +112,26 @@ local function run_plugin_scenario(config)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
for _, value in ipairs(args) do
|
||||||
|
if value == "--app-ping" then
|
||||||
|
config.app_ping_index = (config.app_ping_index or 0) + 1
|
||||||
|
local statuses = config.app_ping_statuses or { 1 }
|
||||||
|
local status = statuses[config.app_ping_index] or statuses[#statuses] or 1
|
||||||
|
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if value == "--stop" and config.stop_command_fails then
|
||||||
|
local stderr = config.stop_command_stderr or "stop failed"
|
||||||
|
callback(false, { status = 1, stdout = "", stderr = stderr }, stderr)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_timeout(seconds, callback)
|
function mp.add_timeout(seconds, callback)
|
||||||
|
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||||
local timeout = {
|
local timeout = {
|
||||||
killed = false,
|
killed = false,
|
||||||
}
|
}
|
||||||
@@ -185,6 +204,9 @@ local function run_plugin_scenario(config)
|
|||||||
name = name,
|
name = name,
|
||||||
value = value,
|
value = value,
|
||||||
}
|
}
|
||||||
|
if name == "pause" then
|
||||||
|
config.paused = value == true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
function mp.set_property(name, value)
|
function mp.set_property(name, value)
|
||||||
recorded.property_sets[#recorded.property_sets + 1] = {
|
recorded.property_sets[#recorded.property_sets + 1] = {
|
||||||
@@ -222,6 +244,10 @@ local function run_plugin_scenario(config)
|
|||||||
return table.concat(parts, "/")
|
return table.concat(parts, "/")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function utils.get_env_list()
|
||||||
|
return config.env_list or {}
|
||||||
|
end
|
||||||
|
|
||||||
function utils.parse_json(json)
|
function utils.parse_json(json)
|
||||||
if json == '{"enabled":true,"amount":125}' then
|
if json == '{"enabled":true,"amount":125}' then
|
||||||
return {
|
return {
|
||||||
@@ -398,6 +424,29 @@ local function find_control_call(async_calls, flag)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function find_nth_control_call(async_calls, flag, target_count)
|
||||||
|
local count = 0
|
||||||
|
for _, call in ipairs(async_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
local has_flag = false
|
||||||
|
local has_start = false
|
||||||
|
for _, value in ipairs(args) do
|
||||||
|
if value == flag then
|
||||||
|
has_flag = true
|
||||||
|
elseif value == "--start" then
|
||||||
|
has_start = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if has_flag and not has_start then
|
||||||
|
count = count + 1
|
||||||
|
if count == target_count then
|
||||||
|
return call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function count_control_calls(async_calls, flag)
|
local function count_control_calls(async_calls, flag)
|
||||||
local count = 0
|
local count = 0
|
||||||
for _, call in ipairs(async_calls) do
|
for _, call in ipairs(async_calls) do
|
||||||
@@ -503,6 +552,35 @@ local function count_osd_message(messages, target)
|
|||||||
return count
|
return count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_timeout(timeouts, target)
|
||||||
|
for _, seconds in ipairs(timeouts) do
|
||||||
|
if math.abs(seconds - target) < 0.0001 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function env_has(call, target)
|
||||||
|
local env = (call and call.env) or {}
|
||||||
|
for _, value in ipairs(env) do
|
||||||
|
if value == target then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function env_has_prefix(call, target)
|
||||||
|
local env = (call and call.env) or {}
|
||||||
|
for _, value in ipairs(env) do
|
||||||
|
if type(value) == "string" and value:sub(1, #target) == target then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local function count_property_set(property_sets, name, value)
|
local function count_property_set(property_sets, name, value)
|
||||||
local count = 0
|
local count = 0
|
||||||
for _, call in ipairs(property_sets) do
|
for _, call in ipairs(property_sets) do
|
||||||
@@ -537,6 +615,7 @@ local function has_key_binding(recorded, keys, name)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local binary_path = "/tmp/subminer-binary"
|
local binary_path = "/tmp/subminer-binary"
|
||||||
|
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
@@ -562,6 +641,139 @@ end
|
|||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = appimage_path,
|
||||||
|
auto_start = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
files = {
|
||||||
|
[appimage_path] = true,
|
||||||
|
},
|
||||||
|
env_list = {
|
||||||
|
"PATH=/usr/bin",
|
||||||
|
"SUBMINER_APP_ARGC=stale",
|
||||||
|
"SUBMINER_APP_ARG_0=--stale",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for AppImage env transport scenario: " .. tostring(err))
|
||||||
|
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||||
|
local call = recorded.async_calls[#recorded.async_calls]
|
||||||
|
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
|
||||||
|
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
|
||||||
|
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
|
||||||
|
assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count")
|
||||||
|
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
|
||||||
|
assert_true(env_has(call, "SUBMINER_APP_ARG_1=--background"), "AppImage subprocess should transport --background")
|
||||||
|
assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
|
||||||
|
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
|
||||||
|
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
|
||||||
|
assert_true(
|
||||||
|
not env_has(call, "SUBMINER_APP_ARG_0=--stale"),
|
||||||
|
"AppImage subprocess should remove stale transported args"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
app_ping_statuses = { 0, 1, 0 },
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
auto_start_visible_overlay = "no",
|
||||||
|
},
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for manual visible restart scenario: " .. tostring(err))
|
||||||
|
local restart_binding = nil
|
||||||
|
for _, candidate in ipairs(recorded.key_bindings) do
|
||||||
|
if candidate.name == "subminer-restart" then
|
||||||
|
restart_binding = candidate
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert_true(restart_binding ~= nil, "restart binding should be registered")
|
||||||
|
restart_binding.fn()
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(start_call ~= nil, "manual restart should issue --start command")
|
||||||
|
local start_index = find_call_index(recorded.async_calls, start_call) or 0
|
||||||
|
local old_app_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 1)
|
||||||
|
local old_app_stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
|
||||||
|
local new_app_started_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
|
||||||
|
assert_true(old_app_ping ~= nil, "manual restart should ping before waiting for old app shutdown")
|
||||||
|
assert_true(old_app_stopped_ping ~= nil, "manual restart should keep pinging until old app shutdown")
|
||||||
|
assert_true(new_app_started_ping ~= nil, "manual restart should ping after start until the new app is running")
|
||||||
|
assert_true(
|
||||||
|
(find_call_index(recorded.async_calls, old_app_ping) or 0) < start_index,
|
||||||
|
"manual restart should wait for old app ping before starting"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
(find_call_index(recorded.async_calls, old_app_stopped_ping) or 0) < start_index,
|
||||||
|
"manual restart should wait for old app stopped ping before starting"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
start_index < (find_call_index(recorded.async_calls, new_app_started_ping) or 0),
|
||||||
|
"manual restart should wait for new app running ping after starting"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--show-visible-overlay"),
|
||||||
|
"manual restart should bring the visible overlay back after process reload"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
|
"manual restart should not restart into hidden visible-overlay state"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_timeout(recorded.timeouts, 0.5),
|
||||||
|
"manual restart should use app-ping readiness instead of a fixed 0.5s start delay"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||||
|
"manual restart should re-assert visible overlay after the restarted app is launched"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
app_ping_statuses = { 0, 2, 1, 0 },
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
auto_start_visible_overlay = "no",
|
||||||
|
},
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(
|
||||||
|
recorded ~= nil,
|
||||||
|
"plugin failed to load for transient app-ping failure restart scenario: " .. tostring(err)
|
||||||
|
)
|
||||||
|
recorded.script_messages["subminer-restart"]()
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(start_call ~= nil, "manual restart should start after app-ping reports stopped")
|
||||||
|
local start_index = find_call_index(recorded.async_calls, start_call) or 0
|
||||||
|
local failed_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
|
||||||
|
local stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
|
||||||
|
assert_true(failed_ping ~= nil, "manual restart should retry after transient app-ping failure")
|
||||||
|
assert_true(stopped_ping ~= nil, "manual restart should observe stopped app-ping status")
|
||||||
|
assert_true(
|
||||||
|
(find_call_index(recorded.async_calls, failed_ping) or 0) < start_index,
|
||||||
|
"manual restart should not treat app-ping status 2 as stopped"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
(find_call_index(recorded.async_calls, stopped_ping) or 0) < start_index,
|
||||||
|
"manual restart should wait for explicit stopped app-ping status"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
app_ping_statuses = { 0, 1, 0 },
|
||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
@@ -570,6 +782,92 @@ do
|
|||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for gated restart pause scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||||
|
"gated restart should start from an armed pause gate"
|
||||||
|
)
|
||||||
|
recorded.script_messages["subminer-restart"]()
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "pause", false) == 0,
|
||||||
|
"manual restart should clear a startup gate without resuming playback"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
app_ping_statuses = { 1, 0 },
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
auto_start_visible_overlay = "no",
|
||||||
|
},
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for restart ready restore scenario: " .. tostring(err))
|
||||||
|
assert_true(
|
||||||
|
recorded.script_messages["subminer-toggle"] ~= nil,
|
||||||
|
"subminer-toggle script message not registered"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
recorded.script_messages["subminer-restart"] ~= nil,
|
||||||
|
"subminer-restart script message not registered"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"] ~= nil,
|
||||||
|
"subminer-autoplay-ready script message not registered"
|
||||||
|
)
|
||||||
|
recorded.script_messages["subminer-toggle"]()
|
||||||
|
recorded.script_messages["subminer-restart"]()
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"]()
|
||||||
|
assert_true(
|
||||||
|
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||||
|
"manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
stop_command_fails = true,
|
||||||
|
stop_command_stderr = "stop refused",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
},
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for failed restart-stop scenario: " .. tostring(err))
|
||||||
|
recorded.script_messages["subminer-restart"]()
|
||||||
|
assert_true(find_control_call(recorded.async_calls, "--stop") ~= nil, "restart should attempt stop")
|
||||||
|
assert_true(count_start_calls(recorded.async_calls) == 0, "restart should not start overlay when stop fails")
|
||||||
|
assert_true(
|
||||||
|
has_osd_message(recorded.osd, "SubMiner: Restart failed"),
|
||||||
|
"restart stop failure should show failure OSD"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
media_title = "Random Movie",
|
media_title = "Random Movie",
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
@@ -608,6 +906,7 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
},
|
},
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
@@ -644,6 +943,7 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "yes",
|
auto_start_pause_until_ready = "yes",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -682,6 +982,7 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "no",
|
auto_start_pause_until_ready = "no",
|
||||||
|
texthooker_enabled = "yes",
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -737,6 +1038,7 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
},
|
},
|
||||||
media_title = "Random Movie",
|
media_title = "Random Movie",
|
||||||
files = {
|
files = {
|
||||||
@@ -765,6 +1067,7 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "no",
|
auto_start_pause_until_ready = "no",
|
||||||
|
texthooker_enabled = "yes",
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -793,6 +1096,7 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
},
|
},
|
||||||
media_title = "Sample Show S01E01",
|
media_title = "Sample Show S01E01",
|
||||||
mal_lookup_stdout = "__MAL_FOUND__",
|
mal_lookup_stdout = "__MAL_FOUND__",
|
||||||
@@ -818,6 +1122,7 @@ do
|
|||||||
option_overrides = {
|
option_overrides = {
|
||||||
binary_path = binary_path,
|
binary_path = binary_path,
|
||||||
auto_start = "no",
|
auto_start = "no",
|
||||||
|
aniskip_enabled = "yes",
|
||||||
},
|
},
|
||||||
media_title = "Sample Show S01E01",
|
media_title = "Sample Show S01E01",
|
||||||
time_pos = 13,
|
time_pos = 13,
|
||||||
@@ -852,6 +1157,7 @@ do
|
|||||||
auto_start = "yes",
|
auto_start = "yes",
|
||||||
auto_start_visible_overlay = "yes",
|
auto_start_visible_overlay = "yes",
|
||||||
auto_start_pause_until_ready = "no",
|
auto_start_pause_until_ready = "no",
|
||||||
|
texthooker_enabled = "yes",
|
||||||
socket_path = "/tmp/subminer-socket",
|
socket_path = "/tmp/subminer-socket",
|
||||||
},
|
},
|
||||||
input_ipc_server = "/tmp/subminer-socket",
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
@@ -1023,6 +1329,37 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
paused = true,
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for pre-paused pause-until-ready scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||||
|
"pre-paused pause-until-ready should still arm the gate"
|
||||||
|
)
|
||||||
|
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"]()
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "pause", false) == 0,
|
||||||
|
"pre-paused pause-until-ready should leave playback paused when ready"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1236,6 +1573,27 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for default config scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(
|
||||||
|
start_call == nil,
|
||||||
|
"plugin should not auto-start from built-in defaults without managed config script opts"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
platform = "windows",
|
platform = "windows",
|
||||||
|
|||||||
@@ -48,3 +48,141 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
|
|||||||
console.info = originalInfo;
|
console.info = originalInfo;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||||
|
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||||
|
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||||
|
};
|
||||||
|
const calls: Array<{ action: string; params: unknown }> = [];
|
||||||
|
client.client = {
|
||||||
|
post: async (_url, body) => {
|
||||||
|
calls.push({ action: body.action, params: body.params });
|
||||||
|
if (body.action === 'deckNames') {
|
||||||
|
return { data: { result: ['Core', 'Mining'], error: null } };
|
||||||
|
}
|
||||||
|
if (body.action === 'modelNames') {
|
||||||
|
return { data: { result: ['Japanese sentences'], error: null } };
|
||||||
|
}
|
||||||
|
if (body.action === 'modelFieldNames') {
|
||||||
|
return { data: { result: ['Expression', 'Sentence'], error: null } };
|
||||||
|
}
|
||||||
|
return { data: { result: [], error: null } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const typedClient = client as unknown as AnkiConnectClient;
|
||||||
|
|
||||||
|
assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']);
|
||||||
|
assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']);
|
||||||
|
assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [
|
||||||
|
'Expression',
|
||||||
|
'Sentence',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.action),
|
||||||
|
['deckNames', 'modelNames', 'modelFieldNames'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||||
|
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||||
|
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||||
|
};
|
||||||
|
const calls: Array<{ action: string; params: unknown }> = [];
|
||||||
|
client.client = {
|
||||||
|
post: async (_url, body) => {
|
||||||
|
calls.push({ action: body.action, params: body.params });
|
||||||
|
if (body.action === 'findNotes') {
|
||||||
|
return { data: { result: [3, 1, 2], error: null } };
|
||||||
|
}
|
||||||
|
if (body.action === 'notesInfo') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{ fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } },
|
||||||
|
{ fields: { Reading: { value: 'z' } } },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: { result: [], error: null } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'),
|
||||||
|
['Expression', 'Reading', 'Sentence'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0], {
|
||||||
|
action: 'findNotes',
|
||||||
|
params: { query: 'deck:"Mining \\"Current\\""' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls[1], {
|
||||||
|
action: 'notesInfo',
|
||||||
|
params: { notes: [3, 1, 2] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiConnectClient treats negative deck note sample sizes as empty samples', async () => {
|
||||||
|
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||||
|
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||||
|
};
|
||||||
|
const calls: Array<{ action: string; params: unknown }> = [];
|
||||||
|
client.client = {
|
||||||
|
post: async (_url, body) => {
|
||||||
|
calls.push({ action: body.action, params: body.params });
|
||||||
|
if (body.action === 'findNotes') {
|
||||||
|
return { data: { result: [3, 1, 2], error: null } };
|
||||||
|
}
|
||||||
|
if (body.action === 'notesInfo') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
result: [{ fields: { Sentence: { value: 'x' } } }],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: { result: [], error: null } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1), []);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.action),
|
||||||
|
['findNotes'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnkiConnectClient derives model names from sampled notes in a deck', async () => {
|
||||||
|
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||||
|
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||||
|
};
|
||||||
|
const calls: Array<{ action: string; params: unknown }> = [];
|
||||||
|
client.client = {
|
||||||
|
post: async (_url, body) => {
|
||||||
|
calls.push({ action: body.action, params: body.params });
|
||||||
|
if (body.action === 'findNotes') {
|
||||||
|
return { data: { result: [5, 4], error: null } };
|
||||||
|
}
|
||||||
|
if (body.action === 'notesInfo') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: { result: [], error: null } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [
|
||||||
|
'Kiku',
|
||||||
|
'Lapis Morph',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((call) => call.action),
|
||||||
|
['findNotes', 'notesInfo'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -156,6 +156,73 @@ export class AnkiConnectClient {
|
|||||||
return (result as number[]) || [];
|
return (result as number[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deckNames(): Promise<string[]> {
|
||||||
|
const result = await this.invoke('deckNames');
|
||||||
|
return Array.isArray(result)
|
||||||
|
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelNames(): Promise<string[]> {
|
||||||
|
const result = await this.invoke('modelNames');
|
||||||
|
return Array.isArray(result)
|
||||||
|
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelFieldNames(modelName: string): Promise<string[]> {
|
||||||
|
const result = await this.invoke('modelFieldNames', { modelName });
|
||||||
|
return Array.isArray(result)
|
||||||
|
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async noteInfosForDeck(
|
||||||
|
deckName: string,
|
||||||
|
sampleSize = 100,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 });
|
||||||
|
if (noteIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
|
||||||
|
const normalizedSampleSize = Math.min(noteIds.length, Math.max(0, Math.floor(finiteSampleSize)));
|
||||||
|
if (normalizedSampleSize === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.notesInfo(noteIds.slice(0, normalizedSampleSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||||
|
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||||
|
const fields = new Set<string>();
|
||||||
|
for (const noteInfo of noteInfos) {
|
||||||
|
const noteFields = noteInfo.fields;
|
||||||
|
if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const fieldName of Object.keys(noteFields)) {
|
||||||
|
fields.add(fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...fields].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||||
|
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||||
|
const modelNames = new Set<string>();
|
||||||
|
for (const noteInfo of noteInfos) {
|
||||||
|
const modelName = noteInfo.modelName;
|
||||||
|
if (typeof modelName === 'string' && modelName.length > 0) {
|
||||||
|
modelNames.add(modelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...modelNames].sort();
|
||||||
|
}
|
||||||
|
|
||||||
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||||
const result = await this.invoke('notesInfo', { notes: noteIds });
|
const result = await this.invoke('notesInfo', { notes: noteIds });
|
||||||
return (result as Record<string, unknown>[]) || [];
|
return (result as Record<string, unknown>[]) || [];
|
||||||
|
|||||||
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isKnownWordCacheEnabled(): boolean {
|
private isKnownWordCacheEnabled(): boolean {
|
||||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
const config = this.deps.getConfig();
|
||||||
|
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldAddMinedWordsImmediately(): boolean {
|
private shouldAddMinedWordsImmediately(): boolean {
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||||
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
const wasKnownWordCacheEnabled =
|
||||||
|
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||||
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
||||||
? this.getKnownWordCacheLifecycleConfig(this.config)
|
? this.getKnownWordCacheLifecycleConfig(this.config)
|
||||||
: null;
|
: null;
|
||||||
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
|
|||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
const nextKnownWordCacheEnabled =
|
||||||
|
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||||
|
|
||||||
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
|
|||||||
@@ -236,6 +236,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(help), false);
|
assert.equal(shouldStartApp(help), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
||||||
|
|
||||||
|
const appPing = parseArgs(['--app-ping']);
|
||||||
|
assert.equal(appPing.appPing, true);
|
||||||
|
assert.equal(hasExplicitCommand(appPing), true);
|
||||||
|
assert.equal(shouldStartApp(appPing), false);
|
||||||
|
|
||||||
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||||
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
|
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface CliArgs {
|
|||||||
texthooker: boolean;
|
texthooker: boolean;
|
||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
|
appPing?: boolean;
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
updateLauncherPath?: string;
|
updateLauncherPath?: string;
|
||||||
updateResponsePath?: string;
|
updateResponsePath?: string;
|
||||||
@@ -172,6 +173,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
texthooker: false,
|
texthooker: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
help: false,
|
help: false,
|
||||||
|
appPing: false,
|
||||||
update: false,
|
update: false,
|
||||||
updateLauncherPath: undefined,
|
updateLauncherPath: undefined,
|
||||||
updateResponsePath: undefined,
|
updateResponsePath: undefined,
|
||||||
@@ -339,6 +341,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||||
else if (arg === '--texthooker') args.texthooker = true;
|
else if (arg === '--texthooker') args.texthooker = true;
|
||||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||||
|
else if (arg === '--app-ping') args.appPing = true;
|
||||||
else if (arg === '--update') args.update = true;
|
else if (arg === '--update') args.update = true;
|
||||||
else if (arg.startsWith('--update-launcher-path=')) {
|
else if (arg.startsWith('--update-launcher-path=')) {
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
@@ -540,6 +543,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.jellyfinRemoteAnnounce ||
|
args.jellyfinRemoteAnnounce ||
|
||||||
args.jellyfinPreviewAuth ||
|
args.jellyfinPreviewAuth ||
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
|
args.appPing ||
|
||||||
args.update ||
|
args.update ||
|
||||||
args.generateConfig ||
|
args.generateConfig ||
|
||||||
args.help
|
args.help
|
||||||
@@ -612,6 +616,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.jellyfinPlay &&
|
!args.jellyfinPlay &&
|
||||||
!args.jellyfinRemoteAnnounce &&
|
!args.jellyfinRemoteAnnounce &&
|
||||||
!args.jellyfinPreviewAuth &&
|
!args.jellyfinPreviewAuth &&
|
||||||
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
@@ -737,6 +742,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.jellyfinRemoteAnnounce &&
|
!args.jellyfinRemoteAnnounce &&
|
||||||
!args.jellyfinPreviewAuth &&
|
!args.jellyfinPreviewAuth &&
|
||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
getNodeValue,
|
||||||
|
parseTree as parseJsoncTree,
|
||||||
|
type Node as JsoncNode,
|
||||||
|
type ParseError,
|
||||||
|
} from 'jsonc-parser';
|
||||||
|
import type { RawConfig } from '../types/config';
|
||||||
|
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||||
|
import { DEFAULT_CONFIG } from './definitions';
|
||||||
|
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||||
|
|
||||||
|
export type LegacyAnkiConnectNPlusOneMigrationResult =
|
||||||
|
| {
|
||||||
|
migrated: true;
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
migrated: false;
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
||||||
|
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
|
||||||
|
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
|
||||||
|
matchMode: 'ankiConnect.knownWords.matchMode',
|
||||||
|
decks: 'ankiConnect.knownWords.decks',
|
||||||
|
knownWord: 'subtitleStyle.knownWordColor',
|
||||||
|
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||||
|
return propertyNode.children?.[0]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
|
||||||
|
return propertyNode?.children?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||||
|
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
|
||||||
|
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||||
|
return matches.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
|
||||||
|
return objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
|
||||||
|
let node = root;
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
node = propertyValue(findLastProperty(node, segment));
|
||||||
|
if (!node) return undefined;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPath(root: JsoncNode | undefined, path: string): boolean {
|
||||||
|
return findValueAtPath(root, path) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyDecks(value: unknown): unknown {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||||
|
const decks = value
|
||||||
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const normalized: Record<string, string[]> = {};
|
||||||
|
for (const deck of new Set(decks)) {
|
||||||
|
normalized[deck] = defaultFields;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||||
|
operations: ConfigSettingsPatchOperation[];
|
||||||
|
hasLegacy: boolean;
|
||||||
|
} {
|
||||||
|
const operations: ConfigSettingsPatchOperation[] = [];
|
||||||
|
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||||
|
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||||
|
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||||
|
if (nPlusOneObjects.length === 0) {
|
||||||
|
return { operations, hasLegacy: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||||
|
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||||
|
let hasLegacy = false;
|
||||||
|
|
||||||
|
for (const nPlusOne of nPlusOneObjects) {
|
||||||
|
for (const property of objectProperties(nPlusOne)) {
|
||||||
|
const key = propertyKey(property);
|
||||||
|
if (!key) continue;
|
||||||
|
const valueNode = propertyValue(property);
|
||||||
|
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||||
|
if (key === 'enabled' || key === 'minSentenceWords') {
|
||||||
|
canonicalNPlusOneValues.set(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||||
|
hasLegacy = true;
|
||||||
|
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nPlusOneObjects.length > 1) {
|
||||||
|
for (const [key, value] of canonicalNPlusOneValues) {
|
||||||
|
operations.push({
|
||||||
|
op: 'set',
|
||||||
|
path: `ankiConnect.nPlusOne.${key}`,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
|
||||||
|
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
|
||||||
|
>) {
|
||||||
|
if (!legacyValues.has(key)) continue;
|
||||||
|
if (!hasPath(root, path)) {
|
||||||
|
const value =
|
||||||
|
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
|
||||||
|
operations.push({
|
||||||
|
op: 'set',
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
operations.push({
|
||||||
|
op: 'reset',
|
||||||
|
path: `ankiConnect.nPlusOne.${key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { operations, hasLegacy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
}): LegacyAnkiConnectNPlusOneMigrationResult {
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
const root = parseJsoncTree(options.content || '{}', errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
});
|
||||||
|
if (!root || errors.length > 0) {
|
||||||
|
return {
|
||||||
|
migrated: false,
|
||||||
|
content: options.content,
|
||||||
|
rawConfig: options.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
|
||||||
|
if (operations.length === 0 && !hasLegacy) {
|
||||||
|
return {
|
||||||
|
migrated: false,
|
||||||
|
content: options.content,
|
||||||
|
rawConfig: options.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyConfigSettingsPatchToContent({
|
||||||
|
content: options.content,
|
||||||
|
operations,
|
||||||
|
previousWarnings: [],
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
migrated: false,
|
||||||
|
content: options.content,
|
||||||
|
rawConfig: options.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
migrated: true,
|
||||||
|
content: result.content,
|
||||||
|
rawConfig: result.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
+297
-21
@@ -12,16 +12,44 @@ import {
|
|||||||
} from './definitions';
|
} from './definitions';
|
||||||
import { parseConfigContent } from './parse';
|
import { parseConfigContent } from './parse';
|
||||||
import { generateConfigTemplate } from './template';
|
import { generateConfigTemplate } from './template';
|
||||||
|
import {
|
||||||
|
buildSubtitleCssDeclarationObject,
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssPath,
|
||||||
|
type SubtitleCssScope,
|
||||||
|
} from '../settings/subtitle-style-css';
|
||||||
|
|
||||||
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
||||||
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
||||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
|
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
|
||||||
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
||||||
|
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||||
|
|
||||||
function makeTempDir(): string {
|
function makeTempDir(): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getValueAtPath(root: unknown, path: string): unknown {
|
||||||
|
let current = root;
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
|
||||||
|
};
|
||||||
|
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||||
|
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
|
||||||
|
}
|
||||||
|
return buildSubtitleCssDeclarationObject(scope, values);
|
||||||
|
}
|
||||||
|
|
||||||
test('loads defaults when config is missing', () => {
|
test('loads defaults when config is missing', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
@@ -83,13 +111,19 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||||
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||||
|
assert.equal(config.subtitleStyle.paintOrder, '');
|
||||||
|
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
|
||||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||||
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
|
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
|
||||||
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
|
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
|
||||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||||
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
|
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
|
||||||
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||||
|
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
|
||||||
|
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
|
||||||
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
|
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
|
||||||
|
assert.deepEqual(config.subtitleSidebar.css, {});
|
||||||
|
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||||
assert.equal(config.immersionTracking.enabled, true);
|
assert.equal(config.immersionTracking.enabled, true);
|
||||||
assert.equal(config.immersionTracking.dbPath, '');
|
assert.equal(config.immersionTracking.dbPath, '');
|
||||||
assert.equal(config.immersionTracking.batchSize, 25);
|
assert.equal(config.immersionTracking.batchSize, 25);
|
||||||
@@ -113,6 +147,13 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.updates.checkIntervalHours, 24);
|
assert.equal(config.updates.checkIntervalHours, 24);
|
||||||
assert.equal(config.updates.notificationType, 'system');
|
assert.equal(config.updates.notificationType, 'system');
|
||||||
assert.equal(config.updates.channel, 'stable');
|
assert.equal(config.updates.channel, 'stable');
|
||||||
|
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||||
|
assert.equal(config.mpv.backend, 'auto');
|
||||||
|
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||||
|
assert.equal(config.mpv.pauseUntilOverlayReady, true);
|
||||||
|
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||||
|
assert.equal(config.mpv.aniskipEnabled, true);
|
||||||
|
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses updates config and warns on invalid values', () => {
|
test('parses updates config and warns on invalid values', () => {
|
||||||
@@ -181,6 +222,94 @@ test('throws actionable startup parse error for malformed config at construction
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"fontSize": 42,
|
||||||
|
"fontColor": "#ffffff",
|
||||||
|
"hoverTokenColor": "#abcdef",
|
||||||
|
"hoverTokenBackgroundColor": "transparent",
|
||||||
|
"css": {
|
||||||
|
"font-size": "44px",
|
||||||
|
"text-wrap": "balance"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"fontSize": 28,
|
||||||
|
"fontColor": "#bbbbbb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subtitleSidebar": {
|
||||||
|
"fontFamily": "M PLUS 1, sans-serif",
|
||||||
|
"fontSize": 18,
|
||||||
|
"textColor": "#dddddd",
|
||||||
|
"timestampColor": "#aaaaaa",
|
||||||
|
"css": {
|
||||||
|
"font-size": "19px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||||
|
subtitleStyle: {
|
||||||
|
fontSize?: unknown;
|
||||||
|
fontColor?: unknown;
|
||||||
|
hoverTokenColor?: unknown;
|
||||||
|
hoverTokenBackgroundColor?: unknown;
|
||||||
|
css?: Record<string, string>;
|
||||||
|
secondary?: {
|
||||||
|
fontSize?: unknown;
|
||||||
|
fontColor?: unknown;
|
||||||
|
css?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
subtitleSidebar: {
|
||||||
|
fontFamily?: unknown;
|
||||||
|
fontSize?: unknown;
|
||||||
|
textColor?: unknown;
|
||||||
|
timestampColor?: unknown;
|
||||||
|
css?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(parsed.subtitleStyle.css, {
|
||||||
|
color: '#ffffff',
|
||||||
|
'font-size': '44px',
|
||||||
|
'--subtitle-hover-token-color': '#abcdef',
|
||||||
|
'--subtitle-hover-token-background-color': 'transparent',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
});
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
|
||||||
|
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
|
||||||
|
color: '#bbbbbb',
|
||||||
|
'font-size': '28px',
|
||||||
|
});
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
|
||||||
|
assert.deepEqual(parsed.subtitleSidebar.css, {
|
||||||
|
'font-family': 'M PLUS 1, sans-serif',
|
||||||
|
color: '#dddddd',
|
||||||
|
'font-size': '19px',
|
||||||
|
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
|
||||||
|
});
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
|
||||||
|
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
|
||||||
|
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
|
||||||
|
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
|
||||||
|
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
|
||||||
|
});
|
||||||
|
|
||||||
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -255,6 +384,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses managed mpv plugin runtime settings from config', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"mpv": {
|
||||||
|
"socketPath": "/tmp/custom-subminer.sock",
|
||||||
|
"backend": "x11",
|
||||||
|
"autoStartSubMiner": false,
|
||||||
|
"pauseUntilOverlayReady": false,
|
||||||
|
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
|
||||||
|
"aniskipEnabled": false,
|
||||||
|
"aniskipButtonKey": "F8"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
const config = validService.getConfig();
|
||||||
|
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
|
||||||
|
assert.equal(config.mpv.backend, 'x11');
|
||||||
|
assert.equal(config.mpv.autoStartSubMiner, false);
|
||||||
|
assert.equal(config.mpv.pauseUntilOverlayReady, false);
|
||||||
|
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
assert.equal(config.mpv.aniskipEnabled, false);
|
||||||
|
assert.equal(config.mpv.aniskipButtonKey, 'F8');
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"mpv": {
|
||||||
|
"socketPath": "",
|
||||||
|
"backend": "weston",
|
||||||
|
"autoStartSubMiner": "yes",
|
||||||
|
"pauseUntilOverlayReady": "no",
|
||||||
|
"subminerBinaryPath": 42,
|
||||||
|
"aniskipEnabled": "disabled",
|
||||||
|
"aniskipButtonKey": ""
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
const invalidConfig = invalidService.getConfig();
|
||||||
|
const warnings = invalidService.getWarnings();
|
||||||
|
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||||
|
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
|
||||||
|
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
|
||||||
|
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
|
||||||
|
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
|
||||||
|
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
|
||||||
|
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
|
||||||
|
});
|
||||||
|
|
||||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||||
const validDir = makeTempDir();
|
const validDir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -1685,6 +1878,7 @@ test('runtime options registry is centralized', () => {
|
|||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
'anki.autoUpdateNewCards',
|
'anki.autoUpdateNewCards',
|
||||||
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
'subtitle.annotation.jlpt',
|
'subtitle.annotation.jlpt',
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -1846,7 +2040,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
|||||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates ankiConnect knownWords and n+1 color values', () => {
|
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
path.join(dir, 'config.jsonc'),
|
||||||
@@ -1867,16 +2061,17 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
|||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
const warnings = service.getWarnings();
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
configPath,
|
||||||
`{
|
`{
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
@@ -1893,14 +2088,23 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
|||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
|
|
||||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
|
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
|
|
||||||
|
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||||
|
ankiConnect: { nPlusOne?: Record<string, unknown> };
|
||||||
|
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
|
||||||
|
};
|
||||||
|
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||||
|
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
|
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
configPath,
|
||||||
`{
|
`{
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
@@ -1918,6 +2122,13 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
|||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
const warnings = service.getWarnings();
|
const warnings = service.getWarnings();
|
||||||
|
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||||
|
ankiConnect: {
|
||||||
|
knownWords: Record<string, unknown>;
|
||||||
|
nPlusOne?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
subtitleStyle: { knownWordColor?: string };
|
||||||
|
};
|
||||||
|
|
||||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||||
@@ -1926,17 +2137,54 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
|||||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||||
});
|
});
|
||||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
|
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
|
||||||
|
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
|
||||||
|
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
|
||||||
|
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
|
||||||
|
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||||
|
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||||
|
});
|
||||||
|
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
warnings.some(
|
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
|
||||||
(warning) =>
|
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
|
||||||
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
|
|
||||||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
|
|
||||||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
|
|
||||||
warning.path === 'ankiConnect.nPlusOne.decks' ||
|
|
||||||
warning.path === 'ankiConnect.nPlusOne.knownWord',
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"nPlusOne": {
|
||||||
|
"enabled": true,
|
||||||
|
"minSentenceWords": 3
|
||||||
|
},
|
||||||
|
"knownWords": {
|
||||||
|
"highlightEnabled": true
|
||||||
|
},
|
||||||
|
"nPlusOne": {
|
||||||
|
"minSentenceWords": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||||
|
ankiConnect: { nPlusOne: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||||
@@ -2280,9 +2528,9 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"characterDictionary":\s*\{/);
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||||
assert.match(output, /"color": "#a6da95"/);
|
assert.match(output, /"knownWordColor": "#a6da95"/);
|
||||||
|
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
|
||||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
|
||||||
assert.match(output, /"minSentenceWords": 3/);
|
assert.match(output, /"minSentenceWords": 3/);
|
||||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -2385,6 +2633,34 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('template generator uses settings CSS declaration paths for appearance fields', () => {
|
||||||
|
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||||
|
const parsed = parseConfigContent('config.example.jsonc', output);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getValueAtPath(parsed, 'subtitleStyle.css'),
|
||||||
|
buildDefaultSubtitleCssDeclarations('primary'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
|
||||||
|
buildDefaultSubtitleCssDeclarations('secondary'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
getValueAtPath(parsed, 'subtitleSidebar.css'),
|
||||||
|
buildDefaultSubtitleCssDeclarations('sidebar'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||||
|
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||||
|
assert.equal(
|
||||||
|
getValueAtPath(parsed, path),
|
||||||
|
undefined,
|
||||||
|
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
||||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||||
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
||||||
|
|||||||
@@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
notificationType: 'system',
|
notificationType: 'system',
|
||||||
channel: 'stable',
|
channel: 'stable',
|
||||||
},
|
},
|
||||||
auto_start_overlay: false,
|
auto_start_overlay: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||||
|
|
||||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
addMinedWordsImmediately: true,
|
addMinedWordsImmediately: true,
|
||||||
matchMode: 'headword',
|
matchMode: 'headword',
|
||||||
decks: {},
|
decks: {},
|
||||||
color: '#a6da95',
|
|
||||||
},
|
},
|
||||||
behavior: {
|
behavior: {
|
||||||
overwriteAudio: true,
|
overwriteAudio: true,
|
||||||
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
autoUpdateNewCards: true,
|
autoUpdateNewCards: true,
|
||||||
},
|
},
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
|
enabled: false,
|
||||||
minSentenceWords: 3,
|
minSentenceWords: 3,
|
||||||
nPlusOne: '#c6a0f6',
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
pattern: '[SubMiner] %f (%t)',
|
pattern: '[SubMiner] %f (%t)',
|
||||||
},
|
},
|
||||||
isLapis: {
|
isLapis: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
sentenceCardModel: 'Japanese sentences',
|
sentenceCardModel: 'Lapis',
|
||||||
},
|
},
|
||||||
isKiku: {
|
isKiku: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
mpv: {
|
mpv: {
|
||||||
executablePath: '',
|
executablePath: '',
|
||||||
launchMode: 'normal',
|
launchMode: 'normal',
|
||||||
|
socketPath: getDefaultMpvSocketPath(),
|
||||||
|
backend: 'auto',
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
subminerBinaryPath: '',
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
},
|
},
|
||||||
anilist: {
|
anilist: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
|
|||||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
primaryDefaultMode: 'visible',
|
primaryDefaultMode: 'visible',
|
||||||
|
css: {},
|
||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
fontKerning: 'normal',
|
fontKerning: 'normal',
|
||||||
textRendering: 'geometricPrecision',
|
textRendering: 'geometricPrecision',
|
||||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||||
|
paintOrder: '',
|
||||||
|
WebkitTextStroke: '',
|
||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
backdropFilter: 'blur(6px)',
|
backdropFilter: 'blur(6px)',
|
||||||
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
css: {},
|
||||||
|
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontColor: '#cad3f5',
|
fontColor: '#cad3f5',
|
||||||
lineHeight: 1.35,
|
lineHeight: 1.35,
|
||||||
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
fontKerning: 'normal',
|
fontKerning: 'normal',
|
||||||
textRendering: 'geometricPrecision',
|
textRendering: 'geometricPrecision',
|
||||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||||
|
paintOrder: '',
|
||||||
|
WebkitTextStroke: '',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
backdropFilter: 'blur(6px)',
|
backdropFilter: 'blur(6px)',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -65,11 +71,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
toggleKey: 'Backslash',
|
toggleKey: 'Backslash',
|
||||||
pauseVideoOnHover: false,
|
pauseVideoOnHover: false,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
|
css: {},
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||||
textColor: '#cad3f5',
|
textColor: '#cad3f5',
|
||||||
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
timestampColor: '#a5adcb',
|
timestampColor: '#a5adcb',
|
||||||
activeLineColor: '#f5bde6',
|
activeLineColor: '#f5bde6',
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
|||||||
'subtitleStyle.jlptColors.N3',
|
'subtitleStyle.jlptColors.N3',
|
||||||
'subtitleStyle.jlptColors.N4',
|
'subtitleStyle.jlptColors.N4',
|
||||||
'subtitleStyle.jlptColors.N5',
|
'subtitleStyle.jlptColors.N5',
|
||||||
'subtitleStyle.knownWordColor',
|
|
||||||
'subtitleStyle.letterSpacing',
|
'subtitleStyle.letterSpacing',
|
||||||
'subtitleStyle.lineHeight',
|
'subtitleStyle.lineHeight',
|
||||||
'subtitleStyle.nPlusOneColor',
|
'subtitleStyle.paintOrder',
|
||||||
'subtitleStyle.secondary.backdropFilter',
|
'subtitleStyle.secondary.backdropFilter',
|
||||||
'subtitleStyle.secondary.backgroundColor',
|
'subtitleStyle.secondary.backgroundColor',
|
||||||
'subtitleStyle.secondary.fontColor',
|
'subtitleStyle.secondary.fontColor',
|
||||||
@@ -77,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
|||||||
'subtitleStyle.secondary.fontWeight',
|
'subtitleStyle.secondary.fontWeight',
|
||||||
'subtitleStyle.secondary.letterSpacing',
|
'subtitleStyle.secondary.letterSpacing',
|
||||||
'subtitleStyle.secondary.lineHeight',
|
'subtitleStyle.secondary.lineHeight',
|
||||||
|
'subtitleStyle.secondary.paintOrder',
|
||||||
'subtitleStyle.secondary.textRendering',
|
'subtitleStyle.secondary.textRendering',
|
||||||
'subtitleStyle.secondary.textShadow',
|
'subtitleStyle.secondary.textShadow',
|
||||||
|
'subtitleStyle.secondary.WebkitTextStroke',
|
||||||
'subtitleStyle.secondary.wordSpacing',
|
'subtitleStyle.secondary.wordSpacing',
|
||||||
'subtitleStyle.textRendering',
|
'subtitleStyle.textRendering',
|
||||||
'subtitleStyle.textShadow',
|
'subtitleStyle.textShadow',
|
||||||
|
'subtitleStyle.WebkitTextStroke',
|
||||||
'subtitleStyle.wordSpacing',
|
'subtitleStyle.wordSpacing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -103,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
|
'mpv.socketPath',
|
||||||
|
'mpv.backend',
|
||||||
|
'mpv.autoStartSubMiner',
|
||||||
|
'mpv.pauseUntilOverlayReady',
|
||||||
|
'mpv.subminerBinaryPath',
|
||||||
|
'mpv.aniskipEnabled',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'yomitan.externalProfilePath',
|
'yomitan.externalProfilePath',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
@@ -112,6 +121,20 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
assert.equal(new Set(paths).size, paths.length);
|
assert.equal(new Set(paths).size, paths.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('known-word annotation color has one public config path', () => {
|
||||||
|
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||||
|
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('n+1 annotation color has one public config path', () => {
|
||||||
|
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||||
|
assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne'));
|
||||||
|
});
|
||||||
|
|
||||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||||
|
|||||||
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
path: 'auto_start_overlay',
|
path: 'auto_start_overlay',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
defaultValue: defaultConfig.auto_start_overlay,
|
defaultValue: defaultConfig.auto_start_overlay,
|
||||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
description:
|
||||||
|
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'secondarySub.secondarySubLanguages',
|
path: 'secondarySub.secondarySubLanguages',
|
||||||
|
|||||||
@@ -278,6 +278,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||||
|
description:
|
||||||
|
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
@@ -291,18 +298,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'ankiConnect.nPlusOne.nPlusOne',
|
|
||||||
kind: 'string',
|
|
||||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
|
|
||||||
description: 'Color used for the single N+1 target token highlight.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'ankiConnect.knownWords.color',
|
|
||||||
kind: 'string',
|
|
||||||
defaultValue: defaultConfig.ankiConnect.knownWords.color,
|
|
||||||
description: 'Color used for known-word highlights.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
@@ -454,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.mpv.launchMode,
|
defaultValue: defaultConfig.mpv.launchMode,
|
||||||
description: 'Default window state for SubMiner-managed mpv launches.',
|
description: 'Default window state for SubMiner-managed mpv launches.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.socketPath',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.mpv.socketPath,
|
||||||
|
description:
|
||||||
|
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.backend',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
|
||||||
|
defaultValue: defaultConfig.mpv.backend,
|
||||||
|
description:
|
||||||
|
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.autoStartSubMiner',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.mpv.autoStartSubMiner,
|
||||||
|
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.pauseUntilOverlayReady',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
|
||||||
|
description:
|
||||||
|
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.subminerBinaryPath',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.mpv.subminerBinaryPath,
|
||||||
|
description:
|
||||||
|
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.aniskipEnabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||||
|
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mpv.aniskipButtonKey',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||||
|
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'jellyfin.enabled',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
@@ -567,7 +609,8 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'discordPresence.presenceStyle',
|
path: 'discordPresence.presenceStyle',
|
||||||
kind: 'string',
|
kind: 'enum',
|
||||||
|
enumValues: ['default', 'meme', 'japanese', 'minimal'],
|
||||||
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
||||||
description:
|
description:
|
||||||
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.css',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.css,
|
||||||
|
description:
|
||||||
|
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.secondary.css',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.secondary.css,
|
||||||
|
description:
|
||||||
|
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.enableJlpt',
|
path: 'subtitleStyle.enableJlpt',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
@@ -69,6 +83,18 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.knownWordColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.knownWordColor,
|
||||||
|
description: 'Color used for known-word subtitle highlights.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.nPlusOneColor',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
|
||||||
|
description: 'Color used for the single N+1 target token subtitle highlight.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
@@ -155,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
|
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
|
||||||
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
|
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleSidebar.css',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleSidebar.css,
|
||||||
|
description:
|
||||||
|
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleSidebar.maxWidth',
|
path: 'subtitleSidebar.maxWidth',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export function buildRuntimeOptionRegistry(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle.annotation.nPlusOne',
|
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||||
label: 'N+1 Annotation',
|
label: 'Known Word Annotation',
|
||||||
scope: 'subtitle',
|
scope: 'subtitle',
|
||||||
valueType: 'boolean',
|
valueType: 'boolean',
|
||||||
allowedValues: [true, false],
|
allowedValues: [true, false],
|
||||||
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle.annotation.nPlusOne',
|
||||||
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
label: 'N+1 Annotation',
|
||||||
|
scope: 'subtitle',
|
||||||
|
valueType: 'boolean',
|
||||||
|
allowedValues: [true, false],
|
||||||
|
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||||
|
requiresRestart: false,
|
||||||
|
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||||
|
toAnkiPatch: (value) => ({
|
||||||
|
nPlusOne: {
|
||||||
|
enabled: value === true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle.annotation.jlpt',
|
id: 'subtitle.annotation.jlpt',
|
||||||
path: 'subtitleStyle.enableJlpt',
|
path: 'subtitleStyle.enableJlpt',
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
|
|||||||
|
|
||||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||||
{
|
{
|
||||||
title: 'Overlay Auto-Start',
|
title: 'Visible Overlay Auto-Start',
|
||||||
description: [
|
description: [
|
||||||
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
|
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
|
||||||
|
'SubMiner can still auto-start in the background when this is false.',
|
||||||
],
|
],
|
||||||
key: 'auto_start_overlay',
|
key: 'auto_start_overlay',
|
||||||
},
|
},
|
||||||
@@ -166,7 +167,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'MPV Launcher',
|
title: 'MPV Launcher',
|
||||||
description: [
|
description: [
|
||||||
'Optional mpv.exe override for Windows playback entry points.',
|
'SubMiner-managed mpv launch and bundled plugin options.',
|
||||||
|
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
|
||||||
|
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
|
||||||
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||||
|
|
||||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||||
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
|
|
||||||
if (knownWordsHighlightEnabled !== undefined) {
|
if (knownWordsHighlightEnabled !== undefined) {
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
||||||
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||||
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.highlightEnabled',
|
|
||||||
nPlusOneConfig.highlightEnabled,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
|
||||||
);
|
|
||||||
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.highlightEnabled',
|
|
||||||
nPlusOneConfig.highlightEnabled,
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
|
||||||
} else {
|
} else {
|
||||||
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
||||||
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
||||||
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||||
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
|
|
||||||
const hasValidKnownWordsRefreshMinutes =
|
const hasValidKnownWordsRefreshMinutes =
|
||||||
knownWordsRefreshMinutes !== undefined &&
|
knownWordsRefreshMinutes !== undefined &&
|
||||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||||
knownWordsRefreshMinutes > 0;
|
knownWordsRefreshMinutes > 0;
|
||||||
const hasValidLegacyNPlusOneRefreshMinutes =
|
|
||||||
legacyNPlusOneRefreshMinutes !== undefined &&
|
|
||||||
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
|
|
||||||
legacyNPlusOneRefreshMinutes > 0;
|
|
||||||
if (knownWordsRefreshMinutes !== undefined) {
|
if (knownWordsRefreshMinutes !== undefined) {
|
||||||
if (hasValidKnownWordsRefreshMinutes) {
|
if (hasValidKnownWordsRefreshMinutes) {
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||||
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||||
}
|
}
|
||||||
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
|
|
||||||
if (hasValidLegacyNPlusOneRefreshMinutes) {
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.refreshMinutes',
|
|
||||||
nPlusOneConfig.refreshMinutes,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.refreshMinutes',
|
|
||||||
nPlusOneConfig.refreshMinutes,
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
|
||||||
'Expected a positive integer.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
|
||||||
}
|
|
||||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
||||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||||
const hasValidLegacyRefreshMinutes =
|
const hasValidLegacyRefreshMinutes =
|
||||||
@@ -789,6 +747,21 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||||
|
if (nPlusOneEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||||
|
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.nPlusOne.enabled',
|
||||||
|
nPlusOneConfig.enabled,
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||||
const hasValidNPlusOneMinSentenceWords =
|
const hasValidNPlusOneMinSentenceWords =
|
||||||
nPlusOneMinSentenceWords !== undefined &&
|
nPlusOneMinSentenceWords !== undefined &&
|
||||||
@@ -813,12 +786,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
||||||
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
|
|
||||||
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||||
const hasValidKnownWordsMatchMode =
|
const hasValidKnownWordsMatchMode =
|
||||||
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
||||||
const hasValidLegacyNPlusOneMatchMode =
|
|
||||||
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
|
|
||||||
const hasValidLegacyMatchMode =
|
const hasValidLegacyMatchMode =
|
||||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||||
if (hasValidKnownWordsMatchMode) {
|
if (hasValidKnownWordsMatchMode) {
|
||||||
@@ -832,25 +802,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
context.resolved.ankiConnect.knownWords.matchMode =
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
|
||||||
if (hasValidLegacyNPlusOneMatchMode) {
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.matchMode',
|
|
||||||
nPlusOneConfig.matchMode,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.matchMode',
|
|
||||||
nPlusOneConfig.matchMode,
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode,
|
|
||||||
"Expected 'headword' or 'surface'.",
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.matchMode =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
|
||||||
}
|
|
||||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
||||||
if (hasValidLegacyMatchMode) {
|
if (hasValidLegacyMatchMode) {
|
||||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||||
@@ -882,7 +833,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
'Word Reading',
|
'Word Reading',
|
||||||
];
|
];
|
||||||
const knownWordsDecks = knownWordsConfig.decks;
|
const knownWordsDecks = knownWordsConfig.decks;
|
||||||
const legacyNPlusOneDecks = nPlusOneConfig.decks;
|
|
||||||
if (isObject(knownWordsDecks)) {
|
if (isObject(knownWordsDecks)) {
|
||||||
const resolved: Record<string, string[]> = {};
|
const resolved: Record<string, string[]> = {};
|
||||||
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
||||||
@@ -926,67 +876,31 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
context.resolved.ankiConnect.knownWords.decks,
|
context.resolved.ankiConnect.knownWords.decks,
|
||||||
'Expected an object mapping deck names to field arrays.',
|
'Expected an object mapping deck names to field arrays.',
|
||||||
);
|
);
|
||||||
} else if (Array.isArray(legacyNPlusOneDecks)) {
|
|
||||||
const normalized = legacyNPlusOneDecks
|
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0);
|
|
||||||
const resolved: Record<string, string[]> = {};
|
|
||||||
for (const deck of new Set(normalized)) {
|
|
||||||
resolved[deck] = DEFAULT_FIELDS;
|
|
||||||
}
|
|
||||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
|
||||||
if (normalized.length > 0) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.decks',
|
|
||||||
legacyNPlusOneDecks,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
||||||
if (nPlusOneHighlightColor !== undefined) {
|
? (context.src.subtitleStyle as Record<string, unknown>)
|
||||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
|
: {};
|
||||||
} else if (nPlusOneConfig.nPlusOne !== undefined) {
|
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.nPlusOne',
|
|
||||||
nPlusOneConfig.nPlusOne,
|
|
||||||
context.resolved.ankiConnect.nPlusOne.nPlusOne,
|
|
||||||
'Expected a hex color value.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||||
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
|
||||||
if (knownWordsColor !== undefined) {
|
if (knownWordsColor !== undefined) {
|
||||||
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
|
if (!hasCanonicalKnownWordColor) {
|
||||||
|
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
||||||
|
}
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.knownWords.color',
|
||||||
|
knownWordsConfig.color,
|
||||||
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
|
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||||
|
);
|
||||||
} else if (knownWordsConfig.color !== undefined) {
|
} else if (knownWordsConfig.color !== undefined) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.knownWords.color',
|
'ankiConnect.knownWords.color',
|
||||||
knownWordsConfig.color,
|
knownWordsConfig.color,
|
||||||
context.resolved.ankiConnect.knownWords.color,
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
'Expected a hex color value.',
|
'Expected a hex color value.',
|
||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
|
||||||
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
|
||||||
nPlusOneConfig.knownWord,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.color,
|
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.color',
|
|
||||||
);
|
|
||||||
} else if (nPlusOneConfig.knownWord !== undefined) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
|
||||||
nPlusOneConfig.knownWord,
|
|
||||||
context.resolved.ankiConnect.knownWords.color,
|
|
||||||
'Expected a hex color value.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
|
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const socketPath = asString(src.mpv.socketPath);
|
||||||
|
if (socketPath !== undefined && socketPath.trim().length > 0) {
|
||||||
|
resolved.mpv.socketPath = socketPath.trim();
|
||||||
|
} else if (src.mpv.socketPath !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.socketPath',
|
||||||
|
src.mpv.socketPath,
|
||||||
|
resolved.mpv.socketPath,
|
||||||
|
'Expected non-empty string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = asString(src.mpv.backend);
|
||||||
|
if (
|
||||||
|
backend === 'auto' ||
|
||||||
|
backend === 'hyprland' ||
|
||||||
|
backend === 'sway' ||
|
||||||
|
backend === 'x11' ||
|
||||||
|
backend === 'macos' ||
|
||||||
|
backend === 'windows'
|
||||||
|
) {
|
||||||
|
resolved.mpv.backend = backend;
|
||||||
|
} else if (src.mpv.backend !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.backend',
|
||||||
|
src.mpv.backend,
|
||||||
|
resolved.mpv.backend,
|
||||||
|
'Expected auto, hyprland, sway, x11, macos, or windows.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
|
||||||
|
if (autoStartSubMiner !== undefined) {
|
||||||
|
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
|
||||||
|
} else if (src.mpv.autoStartSubMiner !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.autoStartSubMiner',
|
||||||
|
src.mpv.autoStartSubMiner,
|
||||||
|
resolved.mpv.autoStartSubMiner,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
|
||||||
|
if (pauseUntilOverlayReady !== undefined) {
|
||||||
|
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
|
||||||
|
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.pauseUntilOverlayReady',
|
||||||
|
src.mpv.pauseUntilOverlayReady,
|
||||||
|
resolved.mpv.pauseUntilOverlayReady,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
|
||||||
|
if (subminerBinaryPath !== undefined) {
|
||||||
|
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
|
||||||
|
} else if (src.mpv.subminerBinaryPath !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.subminerBinaryPath',
|
||||||
|
src.mpv.subminerBinaryPath,
|
||||||
|
resolved.mpv.subminerBinaryPath,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
|
||||||
|
if (aniskipEnabled !== undefined) {
|
||||||
|
resolved.mpv.aniskipEnabled = aniskipEnabled;
|
||||||
|
} else if (src.mpv.aniskipEnabled !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.aniskipEnabled',
|
||||||
|
src.mpv.aniskipEnabled,
|
||||||
|
resolved.mpv.aniskipEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
|
||||||
|
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
|
||||||
|
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
|
||||||
|
} else if (src.mpv.aniskipButtonKey !== undefined) {
|
||||||
|
warn(
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
|
src.mpv.aniskipButtonKey,
|
||||||
|
resolved.mpv.aniskipButtonKey,
|
||||||
|
'Expected non-empty string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (src.mpv !== undefined) {
|
} else if (src.mpv !== undefined) {
|
||||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ import {
|
|||||||
isObject,
|
isObject,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
|
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isObject(value)) return undefined;
|
||||||
|
|
||||||
|
const declarations: Record<string, string> = {};
|
||||||
|
for (const [property, declarationValue] of Object.entries(value)) {
|
||||||
|
if (typeof declarationValue !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (declarationValue.trim().length > 0) {
|
||||||
|
declarations[property] = declarationValue.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
|
||||||
|
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
|
||||||
|
|
||||||
|
function applySubtitleHoverTokenCssCompatibility(
|
||||||
|
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||||
|
): void {
|
||||||
|
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
|
||||||
|
if (hoverTokenColor !== undefined) {
|
||||||
|
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
|
||||||
|
if (hoverTokenBackgroundColor !== undefined) {
|
||||||
|
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
|
||||||
@@ -157,6 +189,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||||
|
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||||
|
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||||
|
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
|
||||||
|
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
|
||||||
const fallbackFrequencyDictionary = {
|
const fallbackFrequencyDictionary = {
|
||||||
...resolved.subtitleStyle.frequencyDictionary,
|
...resolved.subtitleStyle.frequencyDictionary,
|
||||||
};
|
};
|
||||||
@@ -209,6 +245,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
|
||||||
|
if (css !== undefined) {
|
||||||
|
resolved.subtitleStyle.css = css;
|
||||||
|
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
|
||||||
|
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.css',
|
||||||
|
(src.subtitleStyle as { css?: unknown }).css,
|
||||||
|
resolved.subtitleStyle.css,
|
||||||
|
'Expected an object whose values are CSS declaration strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSecondary = isObject(src.subtitleStyle.secondary)
|
||||||
|
? (src.subtitleStyle.secondary as { css?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const secondaryCss = asCssDeclarations(rawSecondary?.css);
|
||||||
|
if (secondaryCss !== undefined) {
|
||||||
|
resolved.subtitleStyle.secondary.css = secondaryCss;
|
||||||
|
} else if (rawSecondary?.css !== undefined) {
|
||||||
|
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
|
rawSecondary.css,
|
||||||
|
resolved.subtitleStyle.secondary.css,
|
||||||
|
'Expected an object whose values are CSS declaration strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const preserveLineBreaks = asBoolean(
|
const preserveLineBreaks = asBoolean(
|
||||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||||
);
|
);
|
||||||
@@ -301,6 +366,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
|
||||||
|
|
||||||
const nameMatchColor = asColor(
|
const nameMatchColor = asColor(
|
||||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||||
);
|
);
|
||||||
@@ -333,6 +400,34 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knownWordColor = asColor(
|
||||||
|
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||||
|
);
|
||||||
|
if (knownWordColor !== undefined) {
|
||||||
|
resolved.subtitleStyle.knownWordColor = knownWordColor;
|
||||||
|
} else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) {
|
||||||
|
resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.knownWordColor',
|
||||||
|
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||||
|
resolved.subtitleStyle.knownWordColor,
|
||||||
|
'Expected hex color.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nPlusOneColor = asColor((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor);
|
||||||
|
if (nPlusOneColor !== undefined) {
|
||||||
|
resolved.subtitleStyle.nPlusOneColor = nPlusOneColor;
|
||||||
|
} else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) {
|
||||||
|
resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.nPlusOneColor',
|
||||||
|
(src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor,
|
||||||
|
resolved.subtitleStyle.nPlusOneColor,
|
||||||
|
'Expected hex color.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const frequencyDictionary = isObject(
|
const frequencyDictionary = isObject(
|
||||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||||
)
|
)
|
||||||
@@ -445,6 +540,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
|
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
|
||||||
|
if (css !== undefined) {
|
||||||
|
resolved.subtitleSidebar.css = css;
|
||||||
|
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
|
||||||
|
resolved.subtitleSidebar.css = fallback.css;
|
||||||
|
warn(
|
||||||
|
'subtitleSidebar.css',
|
||||||
|
(src.subtitleSidebar as { css?: unknown }).css,
|
||||||
|
resolved.subtitleSidebar.css,
|
||||||
|
'Expected an object whose values are CSS declaration strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
|
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
|
||||||
if (enabled !== undefined) {
|
if (enabled !== undefined) {
|
||||||
resolved.subtitleSidebar.enabled = enabled;
|
resolved.subtitleSidebar.enabled = enabled;
|
||||||
|
|||||||
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleSidebar: {
|
||||||
|
css: {
|
||||||
|
'font-size': '18px',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
|
||||||
|
'font-size': '18px',
|
||||||
|
color: '#ffffff',
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleSidebar: {
|
||||||
|
css: {
|
||||||
|
color: 42,
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleSidebar falls back and warns on invalid values', () => {
|
test('subtitleSidebar falls back and warns on invalid values', () => {
|
||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
subtitleSidebar: {
|
subtitleSidebar: {
|
||||||
|
|||||||
@@ -28,6 +28,52 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
css: {
|
||||||
|
'font-size': '42px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
'--subtitle-hover-token-color': '#c6a0f6',
|
||||||
|
'--subtitle-hover-token-background-color': 'transparent',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
css: {
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||||
|
'font-size': '42px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
'--subtitle-hover-token-color': '#c6a0f6',
|
||||||
|
'--subtitle-hover-token-background-color': 'transparent',
|
||||||
|
});
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||||
|
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
css: {
|
||||||
|
'font-size': 42,
|
||||||
|
} as never,
|
||||||
|
secondary: {
|
||||||
|
css: 'font-size: 28px;' as never,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
|
||||||
|
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
@@ -155,6 +201,55 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
knownWordColor: '#ed8796',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796');
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
knownWordColor: 'pink',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
|
assert.ok(
|
||||||
|
invalid.warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.knownWordColor' &&
|
||||||
|
warning.message === 'Expected hex color.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nPlusOneColor: '#ed8796',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796');
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nPlusOneColor: 'pink',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||||
|
assert.ok(
|
||||||
|
invalid.warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.nPlusOneColor' && warning.message === 'Expected hex color.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||||
const valid = createResolveContext({
|
const valid = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
+45
-3
@@ -4,6 +4,8 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
|
|||||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||||
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
|
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
|
||||||
import { resolveConfig } from './resolve';
|
import { resolveConfig } from './resolve';
|
||||||
|
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
|
||||||
|
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
|
||||||
|
|
||||||
export type ReloadConfigStrictResult =
|
export type ReloadConfigStrictResult =
|
||||||
| {
|
| {
|
||||||
@@ -49,7 +51,10 @@ export class ConfigService {
|
|||||||
if (!loadResult.ok) {
|
if (!loadResult.ok) {
|
||||||
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
||||||
}
|
}
|
||||||
this.applyResolvedConfig(loadResult.config, loadResult.path);
|
this.applyResolvedConfig(
|
||||||
|
this.migrateLegacyConfig(loadResult.config, loadResult.path),
|
||||||
|
loadResult.path,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfigPath(): string {
|
getConfigPath(): string {
|
||||||
@@ -70,7 +75,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
reloadConfig(): ResolvedConfig {
|
reloadConfig(): ResolvedConfig {
|
||||||
const { config, path: configPath } = loadRawConfig(this.configPaths);
|
const { config, path: configPath } = loadRawConfig(this.configPaths);
|
||||||
return this.applyResolvedConfig(config, configPath);
|
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||||
@@ -80,7 +85,10 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { config, path: configPath } = loadResult;
|
const { config, path: configPath } = loadResult;
|
||||||
const resolvedConfig = this.applyResolvedConfig(config, configPath);
|
const resolvedConfig = this.applyResolvedConfig(
|
||||||
|
this.migrateLegacyConfig(config, configPath),
|
||||||
|
configPath,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
@@ -113,4 +121,38 @@ export class ConfigService {
|
|||||||
this.warnings = warnings;
|
this.warnings = warnings;
|
||||||
return this.getConfig();
|
return this.getConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let content = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
let rawConfig = config;
|
||||||
|
let migrated = false;
|
||||||
|
for (const applyMigration of [
|
||||||
|
applyLegacyAnkiConnectNPlusOneMigrationToContent,
|
||||||
|
applyLegacySubtitleStyleCssMigrationToContent,
|
||||||
|
]) {
|
||||||
|
const migration = applyMigration({
|
||||||
|
content,
|
||||||
|
rawConfig,
|
||||||
|
});
|
||||||
|
if (!migration.migrated) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
content = migration.content;
|
||||||
|
rawConfig = migration.rawConfig;
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
if (!migrated) {
|
||||||
|
return rawConfig;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(configPath, content, 'utf-8');
|
||||||
|
return rawConfig;
|
||||||
|
} catch {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,39 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
|
|||||||
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
|
||||||
|
const input = `{
|
||||||
|
"ankiConnect": {
|
||||||
|
"nPlusOne": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"knownWords": {
|
||||||
|
"highlightEnabled": true
|
||||||
|
},
|
||||||
|
"nPlusOne": {
|
||||||
|
"minSentenceWords": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = applyConfigSettingsPatchToContent({
|
||||||
|
content: input,
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
previousWarnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const parsed = parse(result.content);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||||
const input = `{
|
const input = `{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import {
|
|||||||
applyEdits,
|
applyEdits,
|
||||||
modify,
|
modify,
|
||||||
parse as parseJsonc,
|
parse as parseJsonc,
|
||||||
|
parseTree as parseJsoncTree,
|
||||||
|
type Edit,
|
||||||
|
type Node as JsoncNode,
|
||||||
type FormattingOptions,
|
type FormattingOptions,
|
||||||
type ParseError,
|
type ParseError,
|
||||||
} from 'jsonc-parser';
|
} from 'jsonc-parser';
|
||||||
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
||||||
|
content = removeDuplicatePropertiesAlongPath(content, operation.path);
|
||||||
const edits = modify(
|
const edits = modify(
|
||||||
content,
|
content,
|
||||||
pathToSegments(operation.path),
|
pathToSegments(operation.path),
|
||||||
@@ -103,6 +107,109 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
|
|||||||
return applyEdits(content, edits);
|
return applyEdits(content, edits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||||
|
return propertyNode.children?.[0]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
|
||||||
|
return propertyNode.children?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||||
|
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespace(value: string | undefined): boolean {
|
||||||
|
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
||||||
|
let index = offset;
|
||||||
|
while (index < content.length && isWhitespace(content[index])) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
||||||
|
let index = offset;
|
||||||
|
while (index >= 0 && isWhitespace(content[index])) {
|
||||||
|
index -= 1;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStartOffset(content: string, offset: number): number {
|
||||||
|
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
|
||||||
|
let offset = propertyNode.offset;
|
||||||
|
let end = propertyNode.offset + propertyNode.length;
|
||||||
|
const next = nextNonWhitespaceOffset(content, end);
|
||||||
|
|
||||||
|
if (content[next] === ',') {
|
||||||
|
end = next + 1;
|
||||||
|
const lineStart = lineStartOffset(content, offset);
|
||||||
|
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
|
||||||
|
offset = lineStart;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const previous = previousNonWhitespaceOffset(content, offset - 1);
|
||||||
|
if (content[previous] === ',') {
|
||||||
|
offset = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
offset,
|
||||||
|
length: Math.max(0, end - offset),
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
|
||||||
|
const errors: ParseError[] = [];
|
||||||
|
let node = parseJsoncTree(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
});
|
||||||
|
if (!node || errors.length > 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const edits: Edit[] = [];
|
||||||
|
for (const segment of pathToSegments(path)) {
|
||||||
|
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const duplicate of matches.slice(0, -1)) {
|
||||||
|
edits.push(removalEditForProperty(content, duplicate));
|
||||||
|
}
|
||||||
|
|
||||||
|
node = propertyValue(matches[matches.length - 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return edits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRemovalEdits(content: string, edits: Edit[]): string {
|
||||||
|
return [...edits]
|
||||||
|
.sort((left, right) => right.offset - left.offset)
|
||||||
|
.reduce(
|
||||||
|
(current, edit) =>
|
||||||
|
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
|
||||||
|
const edits = collectDuplicatePropertyRemovalEdits(content, path);
|
||||||
|
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
|
||||||
|
}
|
||||||
|
|
||||||
function collectModifiedWarnings(
|
function collectModifiedWarnings(
|
||||||
warnings: ConfigValidationWarning[],
|
warnings: ConfigValidationWarning[],
|
||||||
operations: ConfigSettingsPatchOperation[],
|
operations: ConfigSettingsPatchOperation[],
|
||||||
@@ -188,7 +295,7 @@ export function buildConfigSettingsSnapshot(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,39 +1,178 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { DEFAULT_CONFIG } from '../definitions';
|
import { DEFAULT_CONFIG } from '../definitions';
|
||||||
import {
|
import { buildConfigSettingsRegistry } from './registry';
|
||||||
buildConfigSettingsRegistry,
|
|
||||||
getConfigSettingsCoverage,
|
|
||||||
LEGACY_HIDDEN_CONFIG_PATHS,
|
|
||||||
} from './registry';
|
|
||||||
|
|
||||||
test('config settings registry places hover pause under viewing playback behavior', () => {
|
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
|
||||||
const hoverPause = fields.find(
|
function field(path: string) {
|
||||||
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
|
const match = fields.find((candidate) => candidate.configPath === path);
|
||||||
|
assert.ok(match, `missing settings field: ${path}`);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('secondarySub.defaultMode').category, 'behavior');
|
||||||
|
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||||
|
assert.equal(field('auto_start_overlay').category, 'behavior');
|
||||||
|
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
|
||||||
|
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
||||||
|
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||||
|
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||||
|
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
|
||||||
|
assert.ok(
|
||||||
|
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||||
|
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(hoverPause);
|
|
||||||
assert.equal(hoverPause.category, 'viewing');
|
|
||||||
assert.equal(hoverPause.section, 'Playback pause behavior');
|
|
||||||
assert.equal(hoverPause.control, 'boolean');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('config settings registry hides legacy and ignored paths from normal fields', () => {
|
test('settings registry groups annotation display fields by config group', () => {
|
||||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||||
const visiblePaths = new Set(
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||||
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
|
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
|
||||||
|
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
|
||||||
|
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
|
||||||
|
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||||
|
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||||
|
assert.equal(
|
||||||
|
field('subtitleStyle.frequencyDictionary.sourcePath').section,
|
||||||
|
'Frequency Highlighting',
|
||||||
|
);
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||||
|
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||||
|
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||||
|
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||||
|
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||||
|
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||||
|
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||||
|
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||||
|
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
|
||||||
|
assert.equal(field('stats.toggleKey').control, 'key-code');
|
||||||
|
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||||
|
const primaryVisible = fields
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.configPath);
|
||||||
|
const secondaryVisible = fields
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.configPath);
|
||||||
|
|
||||||
|
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
|
||||||
|
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
|
||||||
|
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
|
||||||
|
const sidebarVisible = fields
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.configPath);
|
||||||
|
|
||||||
|
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
|
||||||
|
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry routes playback-related integrations into integrations', () => {
|
||||||
|
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
||||||
|
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||||
|
assert.equal(field('subsync.defaultMode').category, 'integrations');
|
||||||
|
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||||
|
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||||
|
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||||
|
assert.ok(
|
||||||
|
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
|
||||||
|
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
|
||||||
|
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
|
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
|
||||||
assert.equal(visiblePaths.has(path), false, path);
|
assert.deepEqual(
|
||||||
|
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
||||||
|
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||||
|
const paths = new Set(fields.map((candidate) => candidate.configPath));
|
||||||
|
for (const hiddenPath of [
|
||||||
|
'controller.bindings',
|
||||||
|
'controller.preferredGamepadId',
|
||||||
|
'controller.preferredGamepadLabel',
|
||||||
|
'controller.profiles',
|
||||||
|
'youtubeSubgen.whisperBin',
|
||||||
|
'jellyfin.clientVersion',
|
||||||
|
'jellyfin.defaultLibraryId',
|
||||||
|
'jellyfin.deviceId',
|
||||||
|
'jellyfin.clientName',
|
||||||
|
'subtitleSidebar.toggleKey',
|
||||||
|
'jellyfin.recentServers',
|
||||||
|
]) {
|
||||||
|
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||||
}
|
}
|
||||||
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
|
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
|
||||||
});
|
|
||||||
|
|
||||||
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
|
|
||||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
|
||||||
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
|
|
||||||
|
|
||||||
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+380
-44
@@ -6,6 +6,10 @@ import type {
|
|||||||
ConfigSettingsRestartBehavior,
|
ConfigSettingsRestartBehavior,
|
||||||
} from '../../types/settings';
|
} from '../../types/settings';
|
||||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||||
|
import {
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssScopeForPath,
|
||||||
|
} from '../../settings/subtitle-style-css';
|
||||||
|
|
||||||
type Leaf = {
|
type Leaf = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -46,41 +50,184 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'ankiConnect.nPlusOne.matchMode',
|
'ankiConnect.nPlusOne.matchMode',
|
||||||
'ankiConnect.nPlusOne.decks',
|
'ankiConnect.nPlusOne.decks',
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
'ankiConnect.nPlusOne.knownWord',
|
||||||
|
'ankiConnect.nPlusOne.nPlusOne',
|
||||||
|
'ankiConnect.knownWords.color',
|
||||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||||
|
'controller.bindings',
|
||||||
|
'controller.preferredGamepadId',
|
||||||
|
'controller.preferredGamepadLabel',
|
||||||
|
'controller.profiles',
|
||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
'jellyfin.accessToken',
|
'jellyfin.accessToken',
|
||||||
'jellyfin.userId',
|
'jellyfin.userId',
|
||||||
|
'jellyfin.clientName',
|
||||||
|
'jellyfin.clientVersion',
|
||||||
|
'jellyfin.defaultLibraryId',
|
||||||
|
'jellyfin.deviceId',
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
|
'subtitleSidebar.toggleKey',
|
||||||
|
'jellyfin.recentServers',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
|
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
|
||||||
|
|
||||||
const JSON_OBJECT_FIELDS = new Set([
|
const JSON_OBJECT_FIELDS = new Set([
|
||||||
'keybindings',
|
'keybindings',
|
||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'ankiConnect.knownWords.decks',
|
'ankiConnect.knownWords.decks',
|
||||||
|
'subtitleStyle.css',
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
|
'subtitleSidebar.css',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||||
|
|
||||||
const COLOR_SUFFIXES = new Set([
|
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
|
||||||
'Color',
|
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
|
||||||
'color',
|
...getSubtitleCssManagedConfigPaths('primary'),
|
||||||
'backgroundColor',
|
...getSubtitleCssManagedConfigPaths('secondary'),
|
||||||
'singleColor',
|
...getSubtitleCssManagedConfigPaths('sidebar'),
|
||||||
'knownWordColor',
|
|
||||||
'nPlusOne',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||||
|
'appearance',
|
||||||
|
'behavior',
|
||||||
|
'mining-anki',
|
||||||
|
'input',
|
||||||
|
'integrations',
|
||||||
|
'tracking-app',
|
||||||
|
'advanced',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECTION_ORDER = new Map<string, number>(
|
||||||
|
[
|
||||||
|
'Annotation Display',
|
||||||
|
'Known Words',
|
||||||
|
'N+1',
|
||||||
|
'Frequency Highlighting',
|
||||||
|
'Primary Subtitle Appearance',
|
||||||
|
'Secondary Subtitle Appearance',
|
||||||
|
'Subtitle Sidebar Appearance',
|
||||||
|
'Playback Pause Behavior',
|
||||||
|
'Subtitle Behavior',
|
||||||
|
'Subtitle Sidebar Behavior',
|
||||||
|
'Visible Overlay Auto-Start',
|
||||||
|
'YouTube Playback Settings',
|
||||||
|
'MPV Launcher',
|
||||||
|
'Note Fields',
|
||||||
|
'Media Capture',
|
||||||
|
'Kiku/Lapis Features',
|
||||||
|
'Anki AI',
|
||||||
|
'AnkiConnect',
|
||||||
|
'AnkiConnect Proxy',
|
||||||
|
'Jimaku',
|
||||||
|
'Subtitle Sync',
|
||||||
|
'MPV Keybindings',
|
||||||
|
'Overlay Shortcuts',
|
||||||
|
'Controller',
|
||||||
|
'Character Dictionary',
|
||||||
|
].map((section, index) => [section, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const PATH_ORDER = new Map<string, number>(
|
||||||
|
[
|
||||||
|
'ankiConnect.enabled',
|
||||||
|
'ankiConnect.proxy.enabled',
|
||||||
|
'ankiConnect.isLapis.enabled',
|
||||||
|
'ankiConnect.isKiku.enabled',
|
||||||
|
'subtitleStyle.fontColor',
|
||||||
|
'subtitleStyle.backgroundColor',
|
||||||
|
'subtitleStyle.hoverTokenColor',
|
||||||
|
'subtitleStyle.hoverTokenBackgroundColor',
|
||||||
|
'subtitleStyle.css',
|
||||||
|
'subtitleStyle.primaryDefaultMode',
|
||||||
|
'subtitleStyle.secondary.fontColor',
|
||||||
|
'subtitleStyle.secondary.backgroundColor',
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
|
'subtitleSidebar.css',
|
||||||
|
'secondarySub.defaultMode',
|
||||||
|
'secondarySub.secondarySubLanguages',
|
||||||
|
'mpv.autoStartSubMiner',
|
||||||
|
'auto_start_overlay',
|
||||||
|
'mpv.pauseUntilOverlayReady',
|
||||||
|
'mpv.socketPath',
|
||||||
|
'mpv.backend',
|
||||||
|
'mpv.subminerBinaryPath',
|
||||||
|
'mpv.aniskipEnabled',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
|
'mpv.launchMode',
|
||||||
|
'mpv.executablePath',
|
||||||
|
].map((path, index) => [path, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SUBSECTION_ORDER = new Map<string, number>(
|
||||||
|
[
|
||||||
|
'Known Words',
|
||||||
|
'N+1',
|
||||||
|
'JLPT',
|
||||||
|
'Frequency Highlighting',
|
||||||
|
'Character Names',
|
||||||
|
'Mining & Clipboard',
|
||||||
|
'Toggle & Visibility',
|
||||||
|
'Open Panels',
|
||||||
|
'Playback',
|
||||||
|
'Timing',
|
||||||
|
'Default Fold State',
|
||||||
|
].map((subsection, index) => [subsection, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
|
||||||
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
|
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
||||||
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
|
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||||
|
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||||
|
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||||
|
'subtitleStyle.css': 'CSS Declarations',
|
||||||
|
'subtitleStyle.secondary.css': 'CSS Declarations',
|
||||||
|
'subtitleSidebar.css': 'CSS Declarations',
|
||||||
|
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
||||||
|
'subtitlePosition.yPercent': 'Subtitle Position',
|
||||||
|
'mpv.executablePath': 'mpv Executable Path',
|
||||||
|
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
|
||||||
|
'mpv.socketPath': 'mpv IPC Socket Path',
|
||||||
|
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
|
||||||
|
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||||
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||||
|
'ankiConnect.pollingRate':
|
||||||
|
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
|
||||||
|
'ankiConnect.isKiku.enabled':
|
||||||
|
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
|
||||||
|
'ankiConnect.isLapis.enabled':
|
||||||
|
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||||
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'subtitleStyle.css':
|
||||||
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
|
'subtitleStyle.secondary.css':
|
||||||
|
'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.',
|
||||||
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -119,6 +266,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function humanizePath(path: string): string {
|
function humanizePath(path: string): string {
|
||||||
|
const override = LABEL_OVERRIDES[path];
|
||||||
|
if (override) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
const key = path.split('.').at(-1) ?? path;
|
const key = path.split('.').at(-1) ?? path;
|
||||||
const spaced = key
|
const spaced = key
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
@@ -138,7 +289,29 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||||
) {
|
) {
|
||||||
return { category: 'viewing', section: 'Playback pause behavior' };
|
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
||||||
|
}
|
||||||
|
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||||
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||||
|
path === 'ankiConnect.knownWords.decks' ||
|
||||||
|
path === 'ankiConnect.knownWords.matchMode' ||
|
||||||
|
path === 'ankiConnect.knownWords.refreshMinutes'
|
||||||
|
) {
|
||||||
|
return { category: 'behavior', section: 'Known Words' };
|
||||||
|
}
|
||||||
|
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
|
||||||
|
return { category: 'behavior', section: 'N+1' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.mode' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.topX'
|
||||||
|
) {
|
||||||
|
return { category: 'behavior', section: 'Frequency Highlighting' };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('ankiConnect.knownWords.') ||
|
path.startsWith('ankiConnect.knownWords.') ||
|
||||||
@@ -146,62 +319,80 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||||
path === 'subtitleStyle.enableJlpt' ||
|
path === 'subtitleStyle.enableJlpt' ||
|
||||||
|
path === 'subtitleStyle.knownWordColor' ||
|
||||||
|
path === 'subtitleStyle.nPlusOneColor' ||
|
||||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
path === 'subtitleStyle.nameMatchColor'
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
) {
|
) {
|
||||||
return { category: 'viewing', section: 'Annotation display' };
|
return { category: 'appearance', section: 'Annotation Display' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
if (path.startsWith('subtitleStyle.secondary.')) {
|
||||||
return { category: 'viewing', section: 'Secondary subtitle appearance' };
|
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
|
||||||
|
}
|
||||||
|
if (path === 'subtitleStyle.primaryDefaultMode') {
|
||||||
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleStyle.')) {
|
if (path.startsWith('subtitleStyle.')) {
|
||||||
return { category: 'viewing', section: 'Primary subtitle appearance' };
|
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleSidebar.')) {
|
if (path.startsWith('subtitleSidebar.')) {
|
||||||
return { category: 'viewing', section: 'Subtitle sidebar' };
|
const sidebarBehaviorPaths = new Set([
|
||||||
|
'subtitleSidebar.enabled',
|
||||||
|
'subtitleSidebar.autoOpen',
|
||||||
|
'subtitleSidebar.autoScroll',
|
||||||
|
'subtitleSidebar.layout',
|
||||||
|
]);
|
||||||
|
return sidebarBehaviorPaths.has(path)
|
||||||
|
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
|
||||||
|
: { category: 'appearance', section: 'Subtitle Sidebar Appearance' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
||||||
return { category: 'viewing', section: 'Subtitle behavior' };
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.fields.')) {
|
if (path.startsWith('ankiConnect.fields.')) {
|
||||||
return { category: 'mining-anki', section: 'Note fields' };
|
return { category: 'mining-anki', section: 'Note Fields' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||||
return { category: 'mining-anki', section: 'Kiku and Lapis' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
return { category: 'mining-anki', section: 'Anki AI' };
|
return { category: 'mining-anki', section: 'Anki AI' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.proxy.')) {
|
if (path.startsWith('ankiConnect.proxy.')) {
|
||||||
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
|
return { category: 'mining-anki', section: 'AnkiConnect Proxy' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.')) {
|
if (path.startsWith('ankiConnect.')) {
|
||||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||||
}
|
}
|
||||||
if (
|
if (path === 'auto_start_overlay') {
|
||||||
path.startsWith('mpv.') ||
|
return { category: 'behavior', section: topSection(path) };
|
||||||
path.startsWith('youtube.') ||
|
}
|
||||||
path.startsWith('youtubeSubgen.') ||
|
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||||
path.startsWith('jimaku.') ||
|
return { category: 'behavior', section: topSection(path) };
|
||||||
path.startsWith('subsync.')
|
}
|
||||||
) {
|
if (path.startsWith('jimaku.')) {
|
||||||
return { category: 'playback-sources', section: topSection(path) };
|
return { category: 'integrations', section: topSection(path) };
|
||||||
|
}
|
||||||
|
if (path.startsWith('subsync.')) {
|
||||||
|
return { category: 'integrations', section: topSection(path) };
|
||||||
|
}
|
||||||
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('shortcuts.')) {
|
if (path.startsWith('shortcuts.')) {
|
||||||
return { category: 'input', section: 'Overlay shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
if (path === 'keybindings') {
|
if (path === 'keybindings') {
|
||||||
return { category: 'input', section: 'MPV keybindings' };
|
return { category: 'input', section: 'MPV Keybindings' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('controller.')) {
|
if (path.startsWith('controller.')) {
|
||||||
return { category: 'input', section: 'Controller' };
|
return { category: 'input', section: 'Controller' };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('ai.') ||
|
path.startsWith('ai.') ||
|
||||||
path.startsWith('anilist.') ||
|
|
||||||
path.startsWith('yomitan.') ||
|
path.startsWith('yomitan.') ||
|
||||||
path.startsWith('jellyfin.') ||
|
path.startsWith('jellyfin.') ||
|
||||||
path.startsWith('discordPresence.') ||
|
path.startsWith('discordPresence.') ||
|
||||||
@@ -211,13 +402,18 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
) {
|
) {
|
||||||
return { category: 'integrations', section: topSection(path) };
|
return { category: 'integrations', section: topSection(path) };
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('anilist.characterDictionary.')) {
|
||||||
|
return { category: 'integrations', section: 'Character Dictionary' };
|
||||||
|
}
|
||||||
|
if (path.startsWith('anilist.')) {
|
||||||
|
return { category: 'integrations', section: 'AniList' };
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('immersionTracking.') ||
|
path.startsWith('immersionTracking.') ||
|
||||||
path.startsWith('stats.') ||
|
path.startsWith('stats.') ||
|
||||||
path.startsWith('updates.') ||
|
path.startsWith('updates.') ||
|
||||||
path.startsWith('startupWarmups.') ||
|
path.startsWith('startupWarmups.') ||
|
||||||
path.startsWith('logging.') ||
|
path.startsWith('logging.')
|
||||||
path === 'auto_start_overlay'
|
|
||||||
) {
|
) {
|
||||||
return { category: 'tracking-app', section: topSection(path) };
|
return { category: 'tracking-app', section: topSection(path) };
|
||||||
}
|
}
|
||||||
@@ -235,23 +431,40 @@ function topSection(path: string): string {
|
|||||||
jimaku: 'Jimaku',
|
jimaku: 'Jimaku',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
logging: 'Logging',
|
logging: 'Logging',
|
||||||
mpv: 'mpv launcher',
|
mpv: 'MPV Launcher',
|
||||||
stats: 'Stats dashboard',
|
stats: 'Stats dashboard',
|
||||||
startupWarmups: 'Startup warmups',
|
startupWarmups: 'Startup warmups',
|
||||||
subsync: 'Auto subtitle sync',
|
subsync: 'Subtitle Sync',
|
||||||
texthooker: 'Texthooker',
|
texthooker: 'Texthooker',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
websocket: 'WebSocket server',
|
websocket: 'WebSocket server',
|
||||||
yomitan: 'Yomitan',
|
yomitan: 'Yomitan',
|
||||||
youtube: 'YouTube playback',
|
youtube: 'YouTube Playback Settings',
|
||||||
youtubeSubgen: 'YouTube subtitle generation',
|
youtubeSubgen: 'YouTube subtitle generation',
|
||||||
auto_start_overlay: 'Overlay startup',
|
auto_start_overlay: 'Visible Overlay Auto-Start',
|
||||||
};
|
};
|
||||||
return labels[top] ?? humanizePath(top);
|
return labels[top] ?? humanizePath(top);
|
||||||
}
|
}
|
||||||
|
|
||||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||||
if (SECRET_PATHS.has(path)) return 'secret';
|
if (SECRET_PATHS.has(path)) return 'secret';
|
||||||
|
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
|
||||||
|
if (path === 'keybindings') return 'mpv-keybindings';
|
||||||
|
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
||||||
|
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
||||||
|
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
|
||||||
|
if (path.startsWith('shortcuts.'))
|
||||||
|
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
|
||||||
|
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
|
||||||
|
if (
|
||||||
|
path === 'subtitleSidebar.toggleKey' ||
|
||||||
|
path === 'stats.toggleKey' ||
|
||||||
|
path === 'stats.markWatchedKey'
|
||||||
|
) {
|
||||||
|
return 'key-code';
|
||||||
|
}
|
||||||
|
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
||||||
|
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
||||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||||
if (Array.isArray(value)) return 'string-list';
|
if (Array.isArray(value)) return 'string-list';
|
||||||
@@ -266,6 +479,130 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
|||||||
return 'json';
|
return 'json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subsectionForPath(path: string): string | undefined {
|
||||||
|
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
|
||||||
|
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
|
||||||
|
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
||||||
|
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
||||||
|
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
|
||||||
|
return 'JLPT';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.enabled' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.bandedColors'
|
||||||
|
) {
|
||||||
|
return 'Frequency Highlighting';
|
||||||
|
}
|
||||||
|
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
||||||
|
return 'Character Names';
|
||||||
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
|
return 'Toggle & Visibility';
|
||||||
|
}
|
||||||
|
if (path.startsWith('shortcuts.')) {
|
||||||
|
const leaf = path.split('.').at(-1) ?? '';
|
||||||
|
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
|
||||||
|
if (
|
||||||
|
leaf === 'copySubtitle' ||
|
||||||
|
leaf === 'copySubtitleMultiple' ||
|
||||||
|
leaf === 'mineSentence' ||
|
||||||
|
leaf === 'mineSentenceMultiple' ||
|
||||||
|
leaf === 'updateLastCardFromClipboard' ||
|
||||||
|
leaf === 'triggerFieldGrouping' ||
|
||||||
|
leaf === 'markAudioCard'
|
||||||
|
) {
|
||||||
|
return 'Mining & Clipboard';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||||
|
leaf === 'toggleSubtitleSidebar' ||
|
||||||
|
leaf === 'toggleSecondarySub' ||
|
||||||
|
leaf === 'toggleStatsOverlay' ||
|
||||||
|
leaf === 'markWatched'
|
||||||
|
) {
|
||||||
|
return 'Toggle & Visibility';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
leaf === 'openCharacterDictionary' ||
|
||||||
|
leaf === 'openRuntimeOptions' ||
|
||||||
|
leaf === 'openJimaku' ||
|
||||||
|
leaf === 'openSessionHelp' ||
|
||||||
|
leaf === 'openControllerSelect' ||
|
||||||
|
leaf === 'openControllerDebug'
|
||||||
|
) {
|
||||||
|
return 'Open Panels';
|
||||||
|
}
|
||||||
|
if (leaf === 'triggerSubsync') return 'Playback';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeatureToggle(field: ConfigSettingsField): boolean {
|
||||||
|
if (field.control !== 'boolean') return false;
|
||||||
|
const leaf = field.configPath.split('.').at(-1) ?? field.configPath;
|
||||||
|
return (
|
||||||
|
leaf === 'enabled' ||
|
||||||
|
leaf.startsWith('enable') ||
|
||||||
|
leaf.endsWith('Enabled') ||
|
||||||
|
field.label.startsWith('Enable ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTypeRank(field: ConfigSettingsField): number {
|
||||||
|
if (field.control !== 'boolean') return 2;
|
||||||
|
return isFeatureToggle(field) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
|
||||||
|
const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
|
||||||
|
if (category !== 0) return category;
|
||||||
|
|
||||||
|
const section =
|
||||||
|
(SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
if (section !== 0) return section;
|
||||||
|
|
||||||
|
const sectionName = a.section.localeCompare(b.section);
|
||||||
|
if (sectionName !== 0) return sectionName;
|
||||||
|
|
||||||
|
const aSubOrder =
|
||||||
|
a.subsection === undefined
|
||||||
|
? -1
|
||||||
|
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
const bSubOrder =
|
||||||
|
b.subsection === undefined
|
||||||
|
? -1
|
||||||
|
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
const subsection = aSubOrder - bSubOrder;
|
||||||
|
if (subsection !== 0) return subsection;
|
||||||
|
|
||||||
|
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
|
||||||
|
if (subsectionName !== 0) return subsectionName;
|
||||||
|
|
||||||
|
const type = fieldTypeRank(a) - fieldTypeRank(b);
|
||||||
|
if (type !== 0) return type;
|
||||||
|
|
||||||
|
const pathOrder =
|
||||||
|
(PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) -
|
||||||
|
(PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
if (pathOrder !== 0) return pathOrder;
|
||||||
|
|
||||||
|
const label = a.label.localeCompare(b.label);
|
||||||
|
if (label !== 0) return label;
|
||||||
|
return a.configPath.localeCompare(b.configPath);
|
||||||
|
}
|
||||||
|
|
||||||
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||||
if (
|
if (
|
||||||
path === 'keybindings' ||
|
path === 'keybindings' ||
|
||||||
@@ -273,7 +610,9 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
pathStartsWith(path, 'subtitleStyle') ||
|
pathStartsWith(path, 'subtitleStyle') ||
|
||||||
pathStartsWith(path, 'subtitleSidebar') ||
|
pathStartsWith(path, 'subtitleSidebar') ||
|
||||||
path === 'secondarySub.defaultMode' ||
|
path === 'secondarySub.defaultMode' ||
|
||||||
pathStartsWith(path, 'ankiConnect.ai')
|
pathStartsWith(path, 'ankiConnect.ai') ||
|
||||||
|
path === 'stats.toggleKey' ||
|
||||||
|
path === 'stats.markWatchedKey'
|
||||||
) {
|
) {
|
||||||
return 'hot-reload';
|
return 'hot-reload';
|
||||||
}
|
}
|
||||||
@@ -283,13 +622,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||||
const option = OPTION_BY_PATH.get(leaf.path);
|
const option = OPTION_BY_PATH.get(leaf.path);
|
||||||
const { category, section } = categoryAndSection(leaf.path);
|
const { category, section } = categoryAndSection(leaf.path);
|
||||||
|
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
|
||||||
return {
|
return {
|
||||||
id: leaf.path,
|
id: leaf.path,
|
||||||
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
|
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
|
||||||
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
|
description: description ?? `${humanizePath(leaf.path)} setting.`,
|
||||||
configPath: leaf.path,
|
configPath: leaf.path,
|
||||||
category,
|
category,
|
||||||
section,
|
section,
|
||||||
|
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||||
control: controlForPath(leaf.path, leaf.value),
|
control: controlForPath(leaf.path, leaf.value),
|
||||||
defaultValue: leaf.value,
|
defaultValue: leaf.value,
|
||||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||||
@@ -299,6 +640,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
|||||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||||
leaf.path.startsWith('youtubeSubgen.'),
|
leaf.path.startsWith('youtubeSubgen.'),
|
||||||
secret: SECRET_PATHS.has(leaf.path),
|
secret: SECRET_PATHS.has(leaf.path),
|
||||||
|
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,13 +648,7 @@ export function buildConfigSettingsRegistry(
|
|||||||
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
||||||
): ConfigSettingsField[] {
|
): ConfigSettingsField[] {
|
||||||
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
||||||
return leaves.map(fieldForLeaf).sort((a, b) => {
|
return leaves.map(fieldForLeaf).sort(compareFields);
|
||||||
const category = a.category.localeCompare(b.category);
|
|
||||||
if (category !== 0) return category;
|
|
||||||
const section = a.section.localeCompare(b.section);
|
|
||||||
if (section !== 0) return section;
|
|
||||||
return a.configPath.localeCompare(b.configPath);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigSettingsCoverage(
|
export function getConfigSettingsCoverage(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { RawConfig } from '../types/config';
|
||||||
|
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||||
|
import {
|
||||||
|
buildSubtitleCssDeclarationObject,
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssPath,
|
||||||
|
type SubtitleCssScope,
|
||||||
|
} from '../settings/subtitle-style-css';
|
||||||
|
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||||
|
|
||||||
|
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||||
|
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
|
||||||
|
export type LegacySubtitleStyleCssMigrationResult =
|
||||||
|
| {
|
||||||
|
migrated: true;
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
migrated: false;
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueAtPath(root: unknown, path: string): unknown {
|
||||||
|
let current = root;
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
if (!isRecord(current)) return undefined;
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPath(root: unknown, path: string): boolean {
|
||||||
|
let current = root;
|
||||||
|
const segments = path.split('.');
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
|
||||||
|
if (path === 'subtitleStyle.hoverTokenColor') {
|
||||||
|
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
|
||||||
|
}
|
||||||
|
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||||
|
rawConfig: RawConfig,
|
||||||
|
): ConfigSettingsPatchOperation[] {
|
||||||
|
const operations: ConfigSettingsPatchOperation[] = [];
|
||||||
|
|
||||||
|
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||||
|
const cssPath = getSubtitleCssPath(scope);
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
[cssPath]: getValueAtPath(rawConfig, cssPath),
|
||||||
|
};
|
||||||
|
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
|
||||||
|
(legacyPath) =>
|
||||||
|
hasPath(rawConfig, legacyPath) &&
|
||||||
|
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
|
||||||
|
);
|
||||||
|
if (legacyPaths.length === 0) continue;
|
||||||
|
|
||||||
|
for (const legacyPath of legacyPaths) {
|
||||||
|
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
operations.push({
|
||||||
|
op: 'set',
|
||||||
|
path: cssPath,
|
||||||
|
value: buildSubtitleCssDeclarationObject(scope, values),
|
||||||
|
});
|
||||||
|
for (const legacyPath of legacyPaths) {
|
||||||
|
operations.push({ op: 'reset', path: legacyPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
|
||||||
|
content: string;
|
||||||
|
rawConfig: RawConfig;
|
||||||
|
}): LegacySubtitleStyleCssMigrationResult {
|
||||||
|
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
|
||||||
|
if (operations.length === 0) {
|
||||||
|
return {
|
||||||
|
migrated: false,
|
||||||
|
content: options.content,
|
||||||
|
rawConfig: options.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyConfigSettingsPatchToContent({
|
||||||
|
content: options.content,
|
||||||
|
operations,
|
||||||
|
previousWarnings: [],
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
migrated: false,
|
||||||
|
content: options.content,
|
||||||
|
rawConfig: options.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
migrated: true,
|
||||||
|
content: result.content,
|
||||||
|
rawConfig: result.rawConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,11 +6,18 @@ import {
|
|||||||
DEFAULT_KEYBINDINGS,
|
DEFAULT_KEYBINDINGS,
|
||||||
deepCloneConfig,
|
deepCloneConfig,
|
||||||
} from './definitions';
|
} from './definitions';
|
||||||
|
import {
|
||||||
|
buildSubtitleCssDeclarationObject,
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssPath,
|
||||||
|
type SubtitleCssScope,
|
||||||
|
} from '../settings/subtitle-style-css';
|
||||||
|
|
||||||
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||||
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||||
);
|
);
|
||||||
|
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||||
|
|
||||||
function normalizeCommentText(value: string): string {
|
function normalizeCommentText(value: string): string {
|
||||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||||
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
|
|||||||
|
|
||||||
function humanizeKey(key: string): string {
|
function humanizeKey(key: string): string {
|
||||||
const spaced = key
|
const spaced = key
|
||||||
|
.replace(/^--/, '')
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||||
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
|
|||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueAtPath(root: unknown, path: string): unknown {
|
||||||
|
let current = root;
|
||||||
|
for (const segment of path.split('.')) {
|
||||||
|
if (!isRecord(current)) return undefined;
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValueAtPath(root: unknown, path: string, value: unknown): void {
|
||||||
|
const segments = path.split('.').filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
if (!isRecord(current)) return;
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
current[segment] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteValueAtPath(root: unknown, path: string): void {
|
||||||
|
const segments = path.split('.').filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
if (!isRecord(current)) return;
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
delete current[segment];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
|
||||||
|
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||||
|
const cssPath = getSubtitleCssPath(scope);
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
[cssPath]: getValueAtPath(templateConfig, cssPath),
|
||||||
|
};
|
||||||
|
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||||
|
for (const managedPath of managedPaths) {
|
||||||
|
values[managedPath] = getValueAtPath(templateConfig, managedPath);
|
||||||
|
}
|
||||||
|
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
|
||||||
|
for (const managedPath of managedPaths) {
|
||||||
|
deleteValueAtPath(templateConfig, managedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderValue(value: unknown, indent = 0, path = ''): string {
|
function renderValue(value: unknown, indent = 0, path = ''): string {
|
||||||
const pad = ' '.repeat(indent);
|
const pad = ' '.repeat(indent);
|
||||||
const nextPad = ' '.repeat(indent + 2);
|
const nextPad = ' '.repeat(indent + 2);
|
||||||
@@ -106,6 +171,7 @@ function renderSection(
|
|||||||
|
|
||||||
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
||||||
const templateConfig = deepCloneConfig(config);
|
const templateConfig = deepCloneConfig(config);
|
||||||
|
foldSubtitleCssManagedDefaults(templateConfig);
|
||||||
if (templateConfig.keybindings.length === 0) {
|
if (templateConfig.keybindings.length === 0) {
|
||||||
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
||||||
key: binding.key,
|
key: binding.key,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
texthooker: false,
|
texthooker: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
help: false,
|
help: false,
|
||||||
|
appPing: false,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
generateConfig: false,
|
generateConfig: false,
|
||||||
backupOverwrite: false,
|
backupOverwrite: false,
|
||||||
@@ -91,6 +92,9 @@ function createDeps(overrides: Partial<AppLifecycleServiceDeps> = {}) {
|
|||||||
quitApp: () => {
|
quitApp: () => {
|
||||||
calls.push('quitApp');
|
calls.push('quitApp');
|
||||||
},
|
},
|
||||||
|
exitApp: (code) => {
|
||||||
|
calls.push(`exit:${code}`);
|
||||||
|
},
|
||||||
onSecondInstance: () => {},
|
onSecondInstance: () => {},
|
||||||
handleCliCommand: () => {},
|
handleCliCommand: () => {},
|
||||||
printHelp: () => {
|
printHelp: () => {
|
||||||
@@ -136,3 +140,30 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
|
|||||||
|
|
||||||
assert.equal(getLockCalls(), 1);
|
assert.equal(getLockCalls(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
|
||||||
|
const { deps, calls, getLockCalls } = createDeps({
|
||||||
|
shouldStartApp: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
startAppLifecycle(makeArgs({ appPing: true }), deps);
|
||||||
|
|
||||||
|
assert.equal(getLockCalls(), 1);
|
||||||
|
assert.deepEqual(calls, ['exit:1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startAppLifecycle app ping exits zero immediately when another instance owns the lock', () => {
|
||||||
|
let lockCalls = 0;
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
shouldStartApp: () => false,
|
||||||
|
requestSingleInstanceLock: () => {
|
||||||
|
lockCalls += 1;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
startAppLifecycle(makeArgs({ appPing: true }), deps);
|
||||||
|
|
||||||
|
assert.equal(lockCalls, 1);
|
||||||
|
assert.deepEqual(calls, ['exit:0']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface AppLifecycleServiceDeps {
|
|||||||
parseArgs: (argv: string[]) => CliArgs;
|
parseArgs: (argv: string[]) => CliArgs;
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: () => boolean;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
|
exitApp: (code: number) => void;
|
||||||
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
|
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
|
||||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
@@ -27,6 +28,7 @@ export interface AppLifecycleServiceDeps {
|
|||||||
interface AppLike {
|
interface AppLike {
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: () => boolean;
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
exit?: (exitCode?: number) => void;
|
||||||
on: (...args: any[]) => unknown;
|
on: (...args: any[]) => unknown;
|
||||||
whenReady: () => Promise<void>;
|
whenReady: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -54,6 +56,14 @@ export function createAppLifecycleDepsRuntime(
|
|||||||
parseArgs: options.parseArgs,
|
parseArgs: options.parseArgs,
|
||||||
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
|
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
|
||||||
quitApp: () => options.app.quit(),
|
quitApp: () => options.app.quit(),
|
||||||
|
exitApp: (code) => {
|
||||||
|
if (options.app.exit) {
|
||||||
|
options.app.exit(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exitCode = code;
|
||||||
|
options.app.quit();
|
||||||
|
},
|
||||||
onSecondInstance: (handler) => {
|
onSecondInstance: (handler) => {
|
||||||
options.app.on('second-instance', handler as (...args: unknown[]) => void);
|
options.app.on('second-instance', handler as (...args: unknown[]) => void);
|
||||||
},
|
},
|
||||||
@@ -94,6 +104,11 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gotTheLock = deps.requestSingleInstanceLock();
|
const gotTheLock = deps.requestSingleInstanceLock();
|
||||||
|
if (initialArgs.appPing) {
|
||||||
|
deps.exitApp(gotTheLock ? 1 : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -385,6 +385,41 @@ test('MpvIpcClient connect does not force primary subtitle visibility from bindi
|
|||||||
assert.equal(hasPrimaryVisibilityMutation, false);
|
assert.equal(hasPrimaryVisibilityMutation, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MpvIpcClient snapshots current subtitles before connection side effects can hide them', () => {
|
||||||
|
const commands: unknown[] = [];
|
||||||
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
|
(client as any).send = (command: unknown) => {
|
||||||
|
commands.push(command);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
client.on('connection-change', ({ connected }) => {
|
||||||
|
if (connected) {
|
||||||
|
client.setSubVisibility(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbacks = (client as any).transport.callbacks;
|
||||||
|
callbacks.onConnect();
|
||||||
|
|
||||||
|
const firstSubTextSnapshot = commands.findIndex((command) => {
|
||||||
|
const args = (command as { command?: unknown[] }).command;
|
||||||
|
return Array.isArray(args) && args[0] === 'get_property' && args[1] === 'sub-text';
|
||||||
|
});
|
||||||
|
const firstPrimaryHide = commands.findIndex((command) => {
|
||||||
|
const args = (command as { command?: unknown[] }).command;
|
||||||
|
return (
|
||||||
|
Array.isArray(args) &&
|
||||||
|
args[0] === 'set_property' &&
|
||||||
|
args[1] === 'sub-visibility' &&
|
||||||
|
(args[2] === false || args[2] === 'no')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(firstSubTextSnapshot, -1);
|
||||||
|
assert.notEqual(firstPrimaryHide, -1);
|
||||||
|
assert.ok(firstSubTextSnapshot < firstPrimaryHide);
|
||||||
|
});
|
||||||
|
|
||||||
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
|
test('MpvIpcClient setSubVisibility writes compatibility commands for visibility toggle', () => {
|
||||||
const commands: unknown[] = [];
|
const commands: unknown[] = [];
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
|
|||||||
@@ -186,12 +186,12 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.socket = this.transport.getSocket();
|
this.socket = this.transport.getSocket();
|
||||||
this.emit('connection-change', { connected: true });
|
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.hasConnectedOnce = true;
|
this.hasConnectedOnce = true;
|
||||||
this.setSecondarySubVisibility(false);
|
this.setSecondarySubVisibility(false);
|
||||||
subscribeToMpvProperties(this.send.bind(this));
|
subscribeToMpvProperties(this.send.bind(this));
|
||||||
requestMpvInitialState(this.send.bind(this));
|
requestMpvInitialState(this.send.bind(this));
|
||||||
|
this.emit('connection-change', { connected: true });
|
||||||
|
|
||||||
const shouldAutoStart =
|
const shouldAutoStart =
|
||||||
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
|
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
|
||||||
|
|||||||
@@ -11,29 +11,49 @@ type WindowTrackerStub = {
|
|||||||
isTargetWindowMinimized?: () => boolean;
|
isTargetWindowMinimized?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMainWindowRecorder() {
|
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
|
||||||
|
const emitShowImmediately = options.emitShowImmediately ?? true;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
const listeners = new Map<string, Array<() => void>>();
|
||||||
let visible = false;
|
let visible = false;
|
||||||
let focused = false;
|
let focused = false;
|
||||||
let opacity = 1;
|
let opacity = 1;
|
||||||
let contentReady = true;
|
let contentReady = true;
|
||||||
|
const emit = (event: string): void => {
|
||||||
|
const handlers = listeners.get(event) ?? [];
|
||||||
|
listeners.delete(event);
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const emitShow = (): void => {
|
||||||
|
visible = true;
|
||||||
|
emit('show');
|
||||||
|
};
|
||||||
const window = {
|
const window = {
|
||||||
webContents: {},
|
webContents: {},
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
isVisible: () => visible,
|
isVisible: () => visible,
|
||||||
isFocused: () => focused,
|
isFocused: () => focused,
|
||||||
|
once: (event: string, handler: () => void) => {
|
||||||
|
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
|
||||||
|
},
|
||||||
hide: () => {
|
hide: () => {
|
||||||
visible = false;
|
visible = false;
|
||||||
focused = false;
|
focused = false;
|
||||||
calls.push('hide');
|
calls.push('hide');
|
||||||
},
|
},
|
||||||
show: () => {
|
show: () => {
|
||||||
visible = true;
|
|
||||||
calls.push('show');
|
calls.push('show');
|
||||||
|
if (emitShowImmediately) {
|
||||||
|
emitShow();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
showInactive: () => {
|
showInactive: () => {
|
||||||
visible = true;
|
|
||||||
calls.push('show-inactive');
|
calls.push('show-inactive');
|
||||||
|
if (emitShowImmediately) {
|
||||||
|
emitShow();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
focused = true;
|
focused = true;
|
||||||
@@ -68,6 +88,7 @@ function createMainWindowRecorder() {
|
|||||||
window,
|
window,
|
||||||
calls,
|
calls,
|
||||||
getOpacity: () => opacity,
|
getOpacity: () => opacity,
|
||||||
|
emitShow,
|
||||||
setContentReady: (nextContentReady: boolean) => {
|
setContentReady: (nextContentReady: boolean) => {
|
||||||
contentReady = nextContentReady;
|
contentReady = nextContentReady;
|
||||||
(
|
(
|
||||||
@@ -216,6 +237,88 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
|||||||
assert.ok(!calls.includes('osd'));
|
assert.ok(!calls.includes('osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
||||||
|
['update-bounds', 'show', 'update-bounds'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
|
||||||
|
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
|
||||||
|
let width = 1280;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
|
||||||
|
};
|
||||||
|
const run = () =>
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: tracker as never,
|
||||||
|
trackerNotReadyWarningShown: false,
|
||||||
|
setTrackerNotReadyWarningShown: () => {},
|
||||||
|
updateVisibleOverlayBounds: (geometry: { width: number }) => {
|
||||||
|
calls.push(`update-bounds:${geometry.width}`);
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
run();
|
||||||
|
width = 1440;
|
||||||
|
run();
|
||||||
|
emitShow();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.filter((call) => call.startsWith('update-bounds:')),
|
||||||
|
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
|||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ReturnType<typeof setTimeout>
|
ReturnType<typeof setTimeout>
|
||||||
>();
|
>();
|
||||||
|
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
|
||||||
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||||
const opacityCapableWindow = window as BrowserWindow & {
|
const opacityCapableWindow = window as BrowserWindow & {
|
||||||
setOpacity?: (opacity: number) => void;
|
setOpacity?: (opacity: number) => void;
|
||||||
@@ -270,6 +271,32 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
args.markOverlayLoadingOsdShown?.();
|
args.markOverlayLoadingOsdShown?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshNonNativeOverlayBoundsAfterFirstShow = (geometry: WindowGeometry | null): void => {
|
||||||
|
if (
|
||||||
|
geometry === null ||
|
||||||
|
args.isMacOSPlatform ||
|
||||||
|
args.isWindowsPlatform ||
|
||||||
|
mainWindow.isVisible()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
|
||||||
|
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
|
||||||
|
mainWindow.once('show', () => {
|
||||||
|
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
|
||||||
|
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
|
||||||
|
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingGeometry) {
|
||||||
|
args.updateVisibleOverlayBounds(pendingGeometry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!args.visibleOverlayVisible) {
|
if (!args.visibleOverlayVisible) {
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
args.resetOverlayLoadingOsdSuppression?.();
|
args.resetOverlayLoadingOsdSuppression?.();
|
||||||
@@ -298,6 +325,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const geometry = args.windowTracker.getGeometry();
|
const geometry = args.windowTracker.getGeometry();
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
args.updateVisibleOverlayBounds(geometry);
|
args.updateVisibleOverlayBounds(geometry);
|
||||||
|
refreshNonNativeOverlayBoundsAfterFirstShow(geometry);
|
||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
|||||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||||
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'linux',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||||
|
isDev: false,
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||||
|
isDev: false,
|
||||||
|
yomitanSession: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(visibleOptions.resizable, true);
|
||||||
|
assert.equal(modalOptions.resizable, false);
|
||||||
|
} finally {
|
||||||
|
if (originalPlatformDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Windows visible overlay window config does not start as always-on-top', () => {
|
test('Windows visible overlay window config does not start as always-on-top', () => {
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function buildOverlayWindowOptions(
|
|||||||
): BrowserWindowConstructorOptions {
|
): BrowserWindowConstructorOptions {
|
||||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||||
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
|
||||||
|
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -29,7 +30,7 @@ export function buildOverlayWindowOptions(
|
|||||||
frame: false,
|
frame: false,
|
||||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
resizable: false,
|
resizable: shouldAllowCompositorResize,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
focusable: true,
|
focusable: true,
|
||||||
acceptFirstMouse: true,
|
acceptFirstMouse: true,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
|
|||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: (text: string) => boolean;
|
isKnownWord: (text: string) => boolean;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
getNPlusOneEnabled?: () => boolean;
|
getNPlusOneEnabled?: () => boolean;
|
||||||
getJlptEnabled?: () => boolean;
|
getJlptEnabled?: () => boolean;
|
||||||
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: (text: string) => boolean;
|
isKnownWord: (text: string) => boolean;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
getNPlusOneEnabled?: () => boolean;
|
getNPlusOneEnabled?: () => boolean;
|
||||||
getJlptEnabled?: () => boolean;
|
getJlptEnabled?: () => boolean;
|
||||||
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TokenizerAnnotationOptions {
|
interface TokenizerAnnotationOptions {
|
||||||
|
knownWordsEnabled: boolean;
|
||||||
nPlusOneEnabled: boolean;
|
nPlusOneEnabled: boolean;
|
||||||
jlptEnabled: boolean;
|
jlptEnabled: boolean;
|
||||||
nameMatchEnabled: boolean;
|
nameMatchEnabled: boolean;
|
||||||
@@ -119,18 +122,28 @@ function getKnownWordLookup(
|
|||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
options: TokenizerAnnotationOptions,
|
options: TokenizerAnnotationOptions,
|
||||||
): (text: string) => boolean {
|
): (text: string) => boolean {
|
||||||
if (!options.nPlusOneEnabled) {
|
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||||
return () => false;
|
return () => false;
|
||||||
}
|
}
|
||||||
return deps.isKnownWord;
|
return deps.isKnownWord;
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
|
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
|
||||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
return (
|
||||||
|
options.knownWordsEnabled ||
|
||||||
|
options.nPlusOneEnabled ||
|
||||||
|
options.jlptEnabled ||
|
||||||
|
options.frequencyEnabled
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
||||||
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
|
return (
|
||||||
|
options.knownWordsEnabled ||
|
||||||
|
options.nPlusOneEnabled ||
|
||||||
|
options.jlptEnabled ||
|
||||||
|
options.frequencyEnabled
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichTokensWithMecabAsync(
|
async function enrichTokensWithMecabAsync(
|
||||||
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
|
|||||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||||
isKnownWord: options.isKnownWord,
|
isKnownWord: options.isKnownWord,
|
||||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||||
|
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
||||||
getJlptLevel: options.getJlptLevel,
|
getJlptLevel: options.getJlptLevel,
|
||||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||||
getJlptEnabled: options.getJlptEnabled,
|
getJlptEnabled: options.getJlptEnabled,
|
||||||
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
|
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
|
||||||
|
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
|
||||||
return {
|
return {
|
||||||
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
|
knownWordsEnabled: deps.getKnownWordsEnabled
|
||||||
|
? deps.getKnownWordsEnabled() !== false
|
||||||
|
: nPlusOneEnabled,
|
||||||
|
nPlusOneEnabled,
|
||||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||||
|
|||||||
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
|||||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||||
|
const tokens = [
|
||||||
|
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||||
|
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||||
|
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(
|
||||||
|
tokens,
|
||||||
|
makeDeps({
|
||||||
|
isKnownWord: (text) => text === '私' || text === '猫',
|
||||||
|
}),
|
||||||
|
{ nPlusOneEnabled: false, knownWordsEnabled: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, true);
|
||||||
|
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||||
|
assert.equal(result[1]?.isKnown, true);
|
||||||
|
assert.equal(result[1]?.isNPlusOneTarget, false);
|
||||||
|
assert.equal(result[2]?.isKnown, false);
|
||||||
|
assert.equal(result[2]?.isNPlusOneTarget, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
|
||||||
|
const tokens = [
|
||||||
|
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||||
|
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
|
||||||
|
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(
|
||||||
|
tokens,
|
||||||
|
makeDeps({
|
||||||
|
isKnownWord: (text) => text === '私' || text === '猫',
|
||||||
|
}),
|
||||||
|
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, false);
|
||||||
|
assert.equal(result[1]?.isKnown, false);
|
||||||
|
assert.equal(result[2]?.isKnown, false);
|
||||||
|
assert.equal(result[2]?.isNPlusOneTarget, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({
|
makeToken({
|
||||||
@@ -122,6 +166,35 @@ test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 ex
|
|||||||
assert.equal(result[3]?.frequencyRank, 11);
|
assert.equal(result[3]?.frequencyRank, 11);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotateTokens keeps frequency for determiner-led content noun compounds', () => {
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'その場',
|
||||||
|
headword: 'その場',
|
||||||
|
reading: 'そのば',
|
||||||
|
partOfSpeech: PartOfSpeech.noun,
|
||||||
|
pos1: '連体詞|名詞',
|
||||||
|
pos2: '*|一般',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 3,
|
||||||
|
frequencyRank: 879,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(
|
||||||
|
tokens,
|
||||||
|
makeDeps({
|
||||||
|
isKnownWord: (text) => text === 'その場',
|
||||||
|
getJlptLevel: (text) => (text === 'その場' ? 'N4' : null),
|
||||||
|
}),
|
||||||
|
{ minSentenceWordsForNPlusOne: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, true);
|
||||||
|
assert.equal(result[0]?.frequencyRank, 879);
|
||||||
|
assert.equal(result[0]?.jlptLevel, 'N4');
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
|
test('annotateTokens preserves existing frequency rank when frequency is enabled', () => {
|
||||||
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
|
const tokens = [makeToken({ surface: '猫', headword: '猫', frequencyRank: 42 })];
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnnotationStageOptions {
|
export interface AnnotationStageOptions {
|
||||||
|
knownWordsEnabled?: boolean;
|
||||||
nPlusOneEnabled?: boolean;
|
nPlusOneEnabled?: boolean;
|
||||||
nameMatchEnabled?: boolean;
|
nameMatchEnabled?: boolean;
|
||||||
jlptEnabled?: boolean;
|
jlptEnabled?: boolean;
|
||||||
@@ -188,6 +189,35 @@ function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAllowDeterminerLedNounFrequency(
|
||||||
|
normalizedPos1: string,
|
||||||
|
normalizedPos2: string,
|
||||||
|
pos1Exclusions: ReadonlySet<string>,
|
||||||
|
pos2Exclusions: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
const pos1Parts = splitNormalizedTagParts(normalizedPos1);
|
||||||
|
if (pos1Parts.length < 2 || pos1Parts[0] !== '連体詞') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos2Parts = splitNormalizedTagParts(normalizedPos2);
|
||||||
|
if (!isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentCount = Math.max(pos1Parts.length, pos2Parts.length);
|
||||||
|
for (let index = 1; index < componentCount; index += 1) {
|
||||||
|
if (
|
||||||
|
pos1Parts[index] === '名詞' &&
|
||||||
|
!isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isFrequencyExcludedByPos(
|
function isFrequencyExcludedByPos(
|
||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
pos1Exclusions: ReadonlySet<string>,
|
pos1Exclusions: ReadonlySet<string>,
|
||||||
@@ -207,12 +237,19 @@ function isFrequencyExcludedByPos(
|
|||||||
pos1Exclusions,
|
pos1Exclusions,
|
||||||
pos2Exclusions,
|
pos2Exclusions,
|
||||||
);
|
);
|
||||||
|
const allowDeterminerLedNounToken = shouldAllowDeterminerLedNounFrequency(
|
||||||
|
normalizedPos1,
|
||||||
|
normalizedPos2,
|
||||||
|
pos1Exclusions,
|
||||||
|
pos2Exclusions,
|
||||||
|
);
|
||||||
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
|
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
|
||||||
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
|
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
|
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
|
||||||
!allowContentLedMergedToken &&
|
!allowContentLedMergedToken &&
|
||||||
|
!allowDeterminerLedNounToken &&
|
||||||
!allowOrdinalPrefixNounToken &&
|
!allowOrdinalPrefixNounToken &&
|
||||||
!allowHonorificPrefixNounToken
|
!allowHonorificPrefixNounToken
|
||||||
) {
|
) {
|
||||||
@@ -222,6 +259,7 @@ function isFrequencyExcludedByPos(
|
|||||||
if (
|
if (
|
||||||
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
|
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
|
||||||
!allowContentLedMergedToken &&
|
!allowContentLedMergedToken &&
|
||||||
|
!allowDeterminerLedNounToken &&
|
||||||
!allowOrdinalPrefixNounToken &&
|
!allowOrdinalPrefixNounToken &&
|
||||||
!allowHonorificPrefixNounToken
|
!allowHonorificPrefixNounToken
|
||||||
) {
|
) {
|
||||||
@@ -632,13 +670,16 @@ export function annotateTokens(
|
|||||||
): MergedToken[] {
|
): MergedToken[] {
|
||||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||||
|
const knownWordsEnabled = options.knownWordsEnabled !== false;
|
||||||
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
|
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
|
||||||
const nameMatchEnabled = options.nameMatchEnabled !== false;
|
const nameMatchEnabled = options.nameMatchEnabled !== false;
|
||||||
const frequencyEnabled = options.frequencyEnabled !== false;
|
const frequencyEnabled = options.frequencyEnabled !== false;
|
||||||
const jlptEnabled = options.jlptEnabled !== false;
|
const jlptEnabled = options.jlptEnabled !== false;
|
||||||
|
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
|
||||||
|
const nPlusOneKnownStatuses: boolean[] = [];
|
||||||
|
|
||||||
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
||||||
const annotated = tokens.map((token) => {
|
const annotated = tokens.map((token, index) => {
|
||||||
if (
|
if (
|
||||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||||
pos1Exclusions,
|
pos1Exclusions,
|
||||||
@@ -649,6 +690,7 @@ export function annotateTokens(
|
|||||||
pos1Exclusions,
|
pos1Exclusions,
|
||||||
pos2Exclusions,
|
pos2Exclusions,
|
||||||
});
|
});
|
||||||
|
nPlusOneKnownStatuses[index] = false;
|
||||||
return {
|
return {
|
||||||
...strippedToken,
|
...strippedToken,
|
||||||
isKnown: false,
|
isKnown: false,
|
||||||
@@ -656,9 +698,10 @@ export function annotateTokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||||
const isKnown = nPlusOneEnabled
|
const isKnownForMatching = shouldComputeKnownStatus
|
||||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||||
: false;
|
: false;
|
||||||
|
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||||
|
|
||||||
const frequencyRank =
|
const frequencyRank =
|
||||||
frequencyEnabled && !prioritizedNameMatch
|
frequencyEnabled && !prioritizedNameMatch
|
||||||
@@ -672,7 +715,7 @@ export function annotateTokens(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
isKnown,
|
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||||
frequencyRank,
|
frequencyRank,
|
||||||
jlptLevel,
|
jlptLevel,
|
||||||
@@ -691,13 +734,21 @@ export function annotateTokens(
|
|||||||
? minSentenceWordsForNPlusOne
|
? minSentenceWordsForNPlusOne
|
||||||
: 3;
|
: 3;
|
||||||
|
|
||||||
const nPlusOneMarked = markNPlusOneTargets(
|
const nPlusOneMarked = nPlusOneEnabled
|
||||||
annotated,
|
? markNPlusOneTargets(
|
||||||
sanitizedMinSentenceWordsForNPlusOne,
|
annotated.map((token, index) => ({
|
||||||
pos1Exclusions,
|
...token,
|
||||||
pos2Exclusions,
|
isKnown: nPlusOneKnownStatuses[index] ?? false,
|
||||||
options.sourceText,
|
})),
|
||||||
);
|
sanitizedMinSentenceWordsForNPlusOne,
|
||||||
|
pos1Exclusions,
|
||||||
|
pos2Exclusions,
|
||||||
|
options.sourceText,
|
||||||
|
).map((token, index) => ({
|
||||||
|
...annotated[index]!,
|
||||||
|
isNPlusOneTarget: token.isNPlusOneTarget,
|
||||||
|
}))
|
||||||
|
: annotated;
|
||||||
|
|
||||||
if (!nameMatchEnabled) {
|
if (!nameMatchEnabled) {
|
||||||
return nPlusOneMarked;
|
return nPlusOneMarked;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
shouldHandleHelpOnlyAtEntry,
|
shouldHandleHelpOnlyAtEntry,
|
||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
|
hasTransportedStartupArgs,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
|
||||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||||
@@ -55,6 +56,22 @@ test('normalizeStartupArgv defaults no-arg Windows startup to --start only', ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
|
||||||
|
SUBMINER_APP_ARGC: '2',
|
||||||
|
SUBMINER_APP_ARG_0: '--stop',
|
||||||
|
SUBMINER_APP_ARG_1: '--socket',
|
||||||
|
}),
|
||||||
|
['SubMiner.AppImage', '--stop', '--socket'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasTransportedStartupArgs detects env-carried app args', () => {
|
||||||
|
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
|
||||||
|
assert.equal(hasTransportedStartupArgs({}), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const BACKGROUND_ARG = '--background';
|
|||||||
const START_ARG = '--start';
|
const START_ARG = '--start';
|
||||||
const PASSWORD_STORE_ARG = '--password-store';
|
const PASSWORD_STORE_ARG = '--password-store';
|
||||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||||
|
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||||
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
|
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||||
const APP_NAME = 'SubMiner';
|
const APP_NAME = 'SubMiner';
|
||||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||||
'--alang',
|
'--alang',
|
||||||
@@ -83,9 +86,40 @@ function parseCliArgs(argv: string[]): CliArgs {
|
|||||||
return parseArgs(argv);
|
return parseArgs(argv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
||||||
|
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||||
|
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||||
|
if (rawCount === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Number(rawCount);
|
||||||
|
if (!Number.isInteger(count) || count < 0 || count > MAX_TRANSPORTED_APP_ARGS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const value = env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`];
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
args.push(value);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
|
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
|
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
|
||||||
|
|
||||||
|
const transportedArgs = readTransportedStartupArgs(env);
|
||||||
|
if (transportedArgs) {
|
||||||
|
return [argv[0] ?? APP_NAME, ...transportedArgs];
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
||||||
if (effectiveArgs.length === 0) {
|
if (effectiveArgs.length === 0) {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
|||||||
sanitizeBackgroundEnv,
|
sanitizeBackgroundEnv,
|
||||||
sanitizeHelpEnv,
|
sanitizeHelpEnv,
|
||||||
sanitizeLaunchMpvEnv,
|
sanitizeLaunchMpvEnv,
|
||||||
|
hasTransportedStartupArgs,
|
||||||
shouldDetachBackgroundLaunch,
|
shouldDetachBackgroundLaunch,
|
||||||
shouldHandleHelpOnlyAtEntry,
|
shouldHandleHelpOnlyAtEntry,
|
||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
@@ -175,7 +176,8 @@ applySanitizedEnv(sanitizeStartupEnv(process.env));
|
|||||||
const userDataPath = configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
|
|
||||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||||
|
const child = spawn(process.execPath, childArgs, {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: sanitizeBackgroundEnv(process.env),
|
env: sanitizeBackgroundEnv(process.env),
|
||||||
|
|||||||
+98
-24
@@ -139,6 +139,7 @@ import {
|
|||||||
} from './cli/args';
|
} from './cli/args';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||||
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
import {
|
import {
|
||||||
getStartupModeFlags,
|
getStartupModeFlags,
|
||||||
shouldRefreshAnilistOnConfigReload,
|
shouldRefreshAnilistOnConfigReload,
|
||||||
@@ -374,7 +375,6 @@ import {
|
|||||||
detectInstalledMpvPlugin,
|
detectInstalledMpvPlugin,
|
||||||
removeLegacyMpvPluginCandidates,
|
removeLegacyMpvPluginCandidates,
|
||||||
resolvePackagedRuntimePluginPath,
|
resolvePackagedRuntimePluginPath,
|
||||||
syncInstalledFirstRunPluginBinaryPath,
|
|
||||||
} from './main/runtime/first-run-setup-plugin';
|
} from './main/runtime/first-run-setup-plugin';
|
||||||
import {
|
import {
|
||||||
applyWindowsMpvShortcuts,
|
applyWindowsMpvShortcuts,
|
||||||
@@ -493,12 +493,13 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/
|
|||||||
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
|
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
|
||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
|
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
||||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||||
import {
|
import {
|
||||||
createElectronAppUpdater,
|
createElectronAppUpdater,
|
||||||
isNativeUpdaterSupported,
|
isNativeUpdaterSupported,
|
||||||
} from './main/runtime/update/app-updater';
|
} from './main/runtime/update/app-updater';
|
||||||
import { createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
import { createCurlFetch, createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
||||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||||
import {
|
import {
|
||||||
fetchLatestStableRelease,
|
fetchLatestStableRelease,
|
||||||
@@ -534,6 +535,7 @@ import {
|
|||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||||
|
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||||
import {
|
import {
|
||||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
@@ -661,14 +663,18 @@ const texthookerService = new Texthooker(() => {
|
|||||||
const characterDictionaryEnabled =
|
const characterDictionaryEnabled =
|
||||||
config.anilist.characterDictionary.enabled &&
|
config.anilist.characterDictionary.enabled &&
|
||||||
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
||||||
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
|
const knownWordColoringEnabled = getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
config.ankiConnect.knownWords.highlightEnabled,
|
config.ankiConnect.knownWords.highlightEnabled,
|
||||||
);
|
);
|
||||||
|
const nPlusOneColoringEnabled = getRuntimeBooleanOption(
|
||||||
|
'subtitle.annotation.nPlusOne',
|
||||||
|
config.ankiConnect.nPlusOne.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enableKnownWordColoring: knownAndNPlusOneEnabled,
|
enableKnownWordColoring: knownWordColoringEnabled,
|
||||||
enableNPlusOneColoring: knownAndNPlusOneEnabled,
|
enableNPlusOneColoring: nPlusOneColoringEnabled,
|
||||||
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
|
||||||
enableFrequencyColoring: getRuntimeBooleanOption(
|
enableFrequencyColoring: getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -679,8 +685,8 @@ const texthookerService = new Texthooker(() => {
|
|||||||
config.subtitleStyle.enableJlpt,
|
config.subtitleStyle.enableJlpt,
|
||||||
),
|
),
|
||||||
characterDictionaryEnabled,
|
characterDictionaryEnabled,
|
||||||
knownWordColor: config.ankiConnect.knownWords.color,
|
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||||
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
||||||
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
||||||
@@ -719,6 +725,7 @@ type BootServices = MainBootServicesResult<
|
|||||||
{
|
{
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: () => boolean;
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
exit: (code?: number) => void;
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
||||||
whenReady: () => Promise<void>;
|
whenReady: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -1215,6 +1222,17 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
|||||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
|
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) =>
|
||||||
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
socketPath: appState.mpvSocketPath,
|
||||||
|
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
|
||||||
|
backend: getResolvedConfig().mpv.backend,
|
||||||
|
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
|
||||||
|
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
|
||||||
|
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
|
||||||
|
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
|
||||||
|
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||||
|
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
|
||||||
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
|
||||||
@@ -1241,12 +1259,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
|||||||
resourcesPath: process.resourcesPath,
|
resourcesPath: process.resourcesPath,
|
||||||
appExePath: process.execPath,
|
appExePath: process.execPath,
|
||||||
});
|
});
|
||||||
syncInstalledFirstRunPluginBinaryPath({
|
|
||||||
platform: process.platform,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
binaryPath: process.execPath,
|
|
||||||
});
|
|
||||||
const firstRunSetupService = createFirstRunSetupService({
|
const firstRunSetupService = createFirstRunSetupService({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
@@ -1806,7 +1818,9 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
getConfig: () => configService.getConfig(),
|
getConfig: () => configService.getConfig(),
|
||||||
getWarnings: () => configService.getWarnings(),
|
getWarnings: () => configService.getWarnings(),
|
||||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||||
|
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||||
|
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||||
getSettingsWindow: () => appState.configSettingsWindow,
|
getSettingsWindow: () => appState.configSettingsWindow,
|
||||||
setSettingsWindow: (window) => {
|
setSettingsWindow: (window) => {
|
||||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||||
@@ -2563,7 +2577,11 @@ function getResolvedConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeBooleanOption(
|
function getRuntimeBooleanOption(
|
||||||
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
|
id:
|
||||||
|
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||||
|
| 'subtitle.annotation.nPlusOne'
|
||||||
|
| 'subtitle.annotation.jlpt'
|
||||||
|
| 'subtitle.annotation.frequency',
|
||||||
fallback: boolean,
|
fallback: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
const value = appState.runtimeOptionsManager?.getOptionValue(id);
|
const value = appState.runtimeOptionsManager?.getOptionValue(id);
|
||||||
@@ -2572,9 +2590,13 @@ function getRuntimeBooleanOption(
|
|||||||
|
|
||||||
function shouldInitializeMecabForAnnotations(): boolean {
|
function shouldInitializeMecabForAnnotations(): boolean {
|
||||||
const config = getResolvedConfig();
|
const config = getResolvedConfig();
|
||||||
|
const knownWordsEnabled = getRuntimeBooleanOption(
|
||||||
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
|
config.ankiConnect.knownWords.highlightEnabled,
|
||||||
|
);
|
||||||
const nPlusOneEnabled = getRuntimeBooleanOption(
|
const nPlusOneEnabled = getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
config.ankiConnect.knownWords.highlightEnabled,
|
config.ankiConnect.nPlusOne.enabled,
|
||||||
);
|
);
|
||||||
const jlptEnabled = getRuntimeBooleanOption(
|
const jlptEnabled = getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.jlpt',
|
'subtitle.annotation.jlpt',
|
||||||
@@ -2584,7 +2606,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
|
|||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
config.subtitleStyle.frequencyDictionary.enabled,
|
config.subtitleStyle.frequencyDictionary.enabled,
|
||||||
);
|
);
|
||||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -2616,6 +2638,17 @@ const {
|
|||||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
|
getPluginRuntimeConfig: () => ({
|
||||||
|
socketPath: appState.mpvSocketPath,
|
||||||
|
binaryPath: getResolvedConfig().mpv.subminerBinaryPath,
|
||||||
|
backend: getResolvedConfig().mpv.backend,
|
||||||
|
autoStart: getResolvedConfig().mpv.autoStartSubMiner,
|
||||||
|
autoStartVisibleOverlay: getResolvedConfig().auto_start_overlay,
|
||||||
|
autoStartPauseUntilReady: getResolvedConfig().mpv.pauseUntilOverlayReady,
|
||||||
|
texthookerEnabled: getResolvedConfig().texthooker.launchAtStartup,
|
||||||
|
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||||
|
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||||
|
}),
|
||||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||||
removeSocketPath: (socketPath) => {
|
removeSocketPath: (socketPath) => {
|
||||||
@@ -3373,7 +3406,7 @@ const {
|
|||||||
stopConfigHotReload: () => configHotReloadRuntime.stop(),
|
stopConfigHotReload: () => configHotReloadRuntime.stop(),
|
||||||
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
|
restorePreviousSecondarySubVisibility: () => restorePreviousSecondarySubVisibility(),
|
||||||
restoreMpvSubVisibility: () => {
|
restoreMpvSubVisibility: () => {
|
||||||
restoreOverlayMpvSubtitles();
|
restoreOverlayMpvSubtitles({ force: true });
|
||||||
},
|
},
|
||||||
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
|
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
|
||||||
stopSubtitleWebsocket: () => {
|
stopSubtitleWebsocket: () => {
|
||||||
@@ -4015,6 +4048,17 @@ const {
|
|||||||
reportJellyfinRemoteStopped: () => {
|
reportJellyfinRemoteStopped: () => {
|
||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
},
|
},
|
||||||
|
onMpvConnected: () => {
|
||||||
|
if (appState.sessionBindingsInitialized) {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, [
|
||||||
|
'script-message',
|
||||||
|
'subminer-reload-session-bindings',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (appState.currentSubText.trim()) {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
}
|
||||||
|
},
|
||||||
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||||
recordAnilistMediaDuration: (durationSec) => {
|
recordAnilistMediaDuration: (durationSec) => {
|
||||||
recordAnilistMediaDuration(durationSec);
|
recordAnilistMediaDuration(durationSec);
|
||||||
@@ -4182,10 +4226,15 @@ const {
|
|||||||
getKnownWordMatchMode: () =>
|
getKnownWordMatchMode: () =>
|
||||||
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||||
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||||
|
getKnownWordsEnabled: () =>
|
||||||
|
getRuntimeBooleanOption(
|
||||||
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
|
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||||
|
),
|
||||||
getNPlusOneEnabled: () =>
|
getNPlusOneEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
getResolvedConfig().ankiConnect.nPlusOne.enabled,
|
||||||
),
|
),
|
||||||
getMinSentenceWordsForNPlusOne: () =>
|
getMinSentenceWordsForNPlusOne: () =>
|
||||||
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||||
@@ -4656,8 +4705,10 @@ let updateService: ReturnType<typeof createUpdateService> | null = null;
|
|||||||
const electronNetFetch = createElectronNetFetch({
|
const electronNetFetch = createElectronNetFetch({
|
||||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||||
});
|
});
|
||||||
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
function getFetchForUpdater() {
|
function getFetchForUpdater() {
|
||||||
|
if (process.platform === 'linux') return curlFetch;
|
||||||
return electronNetFetch;
|
return electronNetFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5186,8 +5237,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: async () =>
|
tokenizeCurrentSubtitle: async () => {
|
||||||
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||||
|
return resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: appState.currentSubText,
|
||||||
|
currentSubtitleData: appState.currentSubtitleData,
|
||||||
|
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
|
||||||
|
tokenizeSubtitle: tokenizeSubtitleForCurrent
|
||||||
|
? (text) => tokenizeSubtitleForCurrent(text)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
getSubtitleSidebarSnapshot: async () => {
|
getSubtitleSidebarSnapshot: async () => {
|
||||||
@@ -5524,7 +5584,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
|||||||
enforceUnsupportedWaylandMode(args);
|
enforceUnsupportedWaylandMode(args);
|
||||||
},
|
},
|
||||||
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
|
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
|
||||||
getDefaultSocketPath: () => getDefaultSocketPath(),
|
getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(),
|
||||||
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
configDir: CONFIG_DIR,
|
configDir: CONFIG_DIR,
|
||||||
defaultConfig: DEFAULT_CONFIG,
|
defaultConfig: DEFAULT_CONFIG,
|
||||||
@@ -5590,7 +5650,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(),
|
||||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
onWindowContentReady: () => {
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
if (appState.currentSubText.trim()) {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
}
|
||||||
|
},
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||||
@@ -5713,6 +5778,15 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
|||||||
setLoadInFlight: (promise) => {
|
setLoadInFlight: (promise) => {
|
||||||
yomitanLoadInFlight = promise;
|
yomitanLoadInFlight = promise;
|
||||||
},
|
},
|
||||||
|
onYomitanExtensionLoaded: () => {
|
||||||
|
const reloaded = reloadOverlayWindowsForYomitanContentScripts(
|
||||||
|
getOverlayWindows(),
|
||||||
|
(message, error) => logger.warn(message, error),
|
||||||
|
);
|
||||||
|
if (reloaded > 0) {
|
||||||
|
logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||||
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
|
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
type MockAppLifecycleApp = {
|
type MockAppLifecycleApp = {
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: () => boolean;
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
exit: (code?: number) => void;
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
on: (event: string, listener: (...args: unknown[]) => void) => MockAppLifecycleApp;
|
||||||
whenReady: () => Promise<void>;
|
whenReady: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -54,6 +55,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
setPathValue = value;
|
setPathValue = value;
|
||||||
},
|
},
|
||||||
quit: () => {},
|
quit: () => {},
|
||||||
|
exit: (code?: number) => {
|
||||||
|
calls.push(`exit:${code ?? 0}`);
|
||||||
|
},
|
||||||
on: (event: string) => {
|
on: (event: string) => {
|
||||||
appOnCalls.push(event);
|
appOnCalls.push(event);
|
||||||
return {};
|
return {};
|
||||||
@@ -123,8 +127,9 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
services.appLifecycleApp.on('second-instance', () => {}),
|
services.appLifecycleApp.on('second-instance', () => {}),
|
||||||
services.appLifecycleApp,
|
services.appLifecycleApp,
|
||||||
);
|
);
|
||||||
|
services.appLifecycleApp.exit(7);
|
||||||
assert.deepEqual(appOnCalls, ['ready']);
|
assert.deepEqual(appOnCalls, ['ready']);
|
||||||
assert.equal(secondInstanceHandlerRegistered, true);
|
assert.equal(secondInstanceHandlerRegistered, true);
|
||||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);
|
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigStartupParseError } from '../../config';
|
|||||||
export interface AppLifecycleShape {
|
export interface AppLifecycleShape {
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: () => boolean;
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
exit: (code?: number) => void;
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
||||||
whenReady: () => Promise<void>;
|
whenReady: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ export interface MainBootServicesParams<
|
|||||||
app: {
|
app: {
|
||||||
setPath: (name: string, value: string) => void;
|
setPath: (name: string, value: string) => void;
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
|
exit: (code?: number) => void;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||||
on: Function;
|
on: Function;
|
||||||
whenReady: () => Promise<void>;
|
whenReady: () => Promise<void>;
|
||||||
@@ -260,6 +262,7 @@ export function createMainBootServices<
|
|||||||
requestSingleInstanceLock: () =>
|
requestSingleInstanceLock: () =>
|
||||||
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
|
||||||
quit: () => params.app.quit(),
|
quit: () => params.app.quit(),
|
||||||
|
exit: (code?: number) => params.app.exit(code),
|
||||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||||
if (event === 'second-instance') {
|
if (event === 'second-instance') {
|
||||||
params.registerSecondInstanceHandlerEarly(
|
params.registerSecondInstanceHandlerEarly(
|
||||||
|
|||||||
@@ -9,22 +9,40 @@ import * as earlySingleInstance from './early-single-instance';
|
|||||||
|
|
||||||
function createFakeApp(lockValue = true) {
|
function createFakeApp(lockValue = true) {
|
||||||
let requestCalls = 0;
|
let requestCalls = 0;
|
||||||
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
|
let requestData: unknown = null;
|
||||||
|
let secondInstanceListener:
|
||||||
|
| ((
|
||||||
|
_event: unknown,
|
||||||
|
argv: string[],
|
||||||
|
workingDirectory?: string,
|
||||||
|
additionalData?: unknown,
|
||||||
|
) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app: {
|
app: {
|
||||||
requestSingleInstanceLock: () => {
|
requestSingleInstanceLock: (additionalData?: unknown) => {
|
||||||
requestCalls += 1;
|
requestCalls += 1;
|
||||||
|
requestData = additionalData ?? null;
|
||||||
return lockValue;
|
return lockValue;
|
||||||
},
|
},
|
||||||
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
|
on: (
|
||||||
|
_event: 'second-instance',
|
||||||
|
listener: (
|
||||||
|
_event: unknown,
|
||||||
|
argv: string[],
|
||||||
|
workingDirectory?: string,
|
||||||
|
additionalData?: unknown,
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
secondInstanceListener = listener;
|
secondInstanceListener = listener;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emitSecondInstance: (argv: string[]) => {
|
emitSecondInstance: (argv: string[], additionalData?: unknown) => {
|
||||||
secondInstanceListener?.({}, argv);
|
secondInstanceListener?.({}, argv, '/tmp', additionalData);
|
||||||
},
|
},
|
||||||
getRequestCalls: () => requestCalls,
|
getRequestCalls: () => requestCalls,
|
||||||
|
getRequestData: () => requestData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +74,23 @@ test('registerSecondInstanceHandlerEarly replays queued argv and forwards new ev
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('requestSingleInstanceLockEarly sends normalized argv through second-instance data', () => {
|
||||||
|
resetEarlySingleInstanceStateForTests();
|
||||||
|
const fake = createFakeApp(true);
|
||||||
|
const primaryArgv = ['SubMiner.AppImage', '--start'];
|
||||||
|
const transportedArgv = ['SubMiner.AppImage', '--stop'];
|
||||||
|
const calls: string[][] = [];
|
||||||
|
|
||||||
|
assert.equal(requestSingleInstanceLockEarly(fake.app, primaryArgv), true);
|
||||||
|
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
|
||||||
|
calls.push(argv);
|
||||||
|
});
|
||||||
|
fake.emitSecondInstance(['SubMiner.AppImage'], { subminerArgv: transportedArgv });
|
||||||
|
|
||||||
|
assert.deepEqual(fake.getRequestData(), { subminerArgv: primaryArgv });
|
||||||
|
assert.deepEqual(calls, [transportedArgv]);
|
||||||
|
});
|
||||||
|
|
||||||
test('stats daemon args bypass the normal single-instance lock path', () => {
|
test('stats daemon args bypass the normal single-instance lock path', () => {
|
||||||
const shouldBypass = (
|
const shouldBypass = (
|
||||||
earlySingleInstance as typeof earlySingleInstance & {
|
earlySingleInstance as typeof earlySingleInstance & {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
interface ElectronSecondInstanceAppLike {
|
interface ElectronSecondInstanceAppLike {
|
||||||
requestSingleInstanceLock: () => boolean;
|
requestSingleInstanceLock: (additionalData?: Record<string, unknown>) => boolean;
|
||||||
on: (event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => unknown;
|
on: (
|
||||||
|
event: 'second-instance',
|
||||||
|
listener: (
|
||||||
|
_event: unknown,
|
||||||
|
argv: string[],
|
||||||
|
workingDirectory?: string,
|
||||||
|
additionalData?: unknown,
|
||||||
|
) => void,
|
||||||
|
) => unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SECOND_INSTANCE_ARGV_KEY = 'subminerArgv';
|
||||||
|
|
||||||
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
|
export function shouldBypassSingleInstanceLockForArgv(argv: readonly string[]): boolean {
|
||||||
return argv.includes('--stats-background') || argv.includes('--stats-stop');
|
return argv.includes('--stats-background') || argv.includes('--stats-stop');
|
||||||
}
|
}
|
||||||
@@ -12,10 +22,24 @@ let secondInstanceListenerAttached = false;
|
|||||||
const secondInstanceArgvHistory: string[][] = [];
|
const secondInstanceArgvHistory: string[][] = [];
|
||||||
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
|
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
|
||||||
|
|
||||||
|
function normalizeSecondInstanceArgv(argv: string[], additionalData: unknown): string[] {
|
||||||
|
if (
|
||||||
|
additionalData &&
|
||||||
|
typeof additionalData === 'object' &&
|
||||||
|
Array.isArray((additionalData as { subminerArgv?: unknown }).subminerArgv) &&
|
||||||
|
(additionalData as { subminerArgv: unknown[] }).subminerArgv.every(
|
||||||
|
(value) => typeof value === 'string',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [...(additionalData as { subminerArgv: string[] }).subminerArgv];
|
||||||
|
}
|
||||||
|
return [...argv];
|
||||||
|
}
|
||||||
|
|
||||||
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
|
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
|
||||||
if (secondInstanceListenerAttached) return;
|
if (secondInstanceListenerAttached) return;
|
||||||
app.on('second-instance', (event, argv) => {
|
app.on('second-instance', (event, argv, _workingDirectory, additionalData) => {
|
||||||
const clonedArgv = [...argv];
|
const clonedArgv = normalizeSecondInstanceArgv(argv, additionalData);
|
||||||
secondInstanceArgvHistory.push(clonedArgv);
|
secondInstanceArgvHistory.push(clonedArgv);
|
||||||
for (const handler of secondInstanceHandlers) {
|
for (const handler of secondInstanceHandlers) {
|
||||||
handler(event, [...clonedArgv]);
|
handler(event, [...clonedArgv]);
|
||||||
@@ -24,12 +48,17 @@ function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void
|
|||||||
secondInstanceListenerAttached = true;
|
secondInstanceListenerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
|
export function requestSingleInstanceLockEarly(
|
||||||
|
app: ElectronSecondInstanceAppLike,
|
||||||
|
argv: readonly string[] = process.argv,
|
||||||
|
): boolean {
|
||||||
attachSecondInstanceListener(app);
|
attachSecondInstanceListener(app);
|
||||||
if (cachedSingleInstanceLock !== null) {
|
if (cachedSingleInstanceLock !== null) {
|
||||||
return cachedSingleInstanceLock;
|
return cachedSingleInstanceLock;
|
||||||
}
|
}
|
||||||
cachedSingleInstanceLock = app.requestSingleInstanceLock();
|
cachedSingleInstanceLock = app.requestSingleInstanceLock({
|
||||||
|
[SECOND_INSTANCE_ARGV_KEY]: [...argv],
|
||||||
|
});
|
||||||
return cachedSingleInstanceLock;
|
return cachedSingleInstanceLock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
|
|||||||
const shouldInitializeMecabForAnnotations = (): boolean => {
|
const shouldInitializeMecabForAnnotations = (): boolean => {
|
||||||
const nPlusOneEnabled =
|
const nPlusOneEnabled =
|
||||||
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
|
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
|
||||||
|
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
|
||||||
|
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
|
||||||
|
: nPlusOneEnabled;
|
||||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||||
const frequencyEnabled =
|
const frequencyEnabled =
|
||||||
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
|
||||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||||
};
|
};
|
||||||
const shouldWarmupAnnotationDictionaries = (): boolean => {
|
const shouldWarmupAnnotationDictionaries = (): boolean => {
|
||||||
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...config.subtitleStyle,
|
...config.subtitleStyle,
|
||||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||||
knownWordColor: config.ankiConnect.knownWords.color,
|
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
|
|||||||
label: 'Launch mode',
|
label: 'Launch mode',
|
||||||
description: 'Launch mode setting.',
|
description: 'Launch mode setting.',
|
||||||
configPath: 'mpv.launchMode',
|
configPath: 'mpv.launchMode',
|
||||||
category: 'playback-sources',
|
category: 'behavior',
|
||||||
section: 'mpv launcher',
|
section: 'MPV Launcher',
|
||||||
control: 'select',
|
control: 'select',
|
||||||
defaultValue: 'windowed',
|
defaultValue: 'windowed',
|
||||||
restartBehavior: 'restart',
|
restartBehavior: 'restart',
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ import path from 'node:path';
|
|||||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||||
import type {
|
import type {
|
||||||
|
ConfigSettingsAnkiListResult,
|
||||||
ConfigSettingsField,
|
ConfigSettingsField,
|
||||||
ConfigSettingsSaveResult,
|
ConfigSettingsSaveResult,
|
||||||
ConfigSettingsSnapshot,
|
ConfigSettingsSnapshot,
|
||||||
} from '../../types/settings';
|
} from '../../types/settings';
|
||||||
import type { ReloadConfigStrictResult } from '../../config';
|
import type { ReloadConfigStrictResult } from '../../config';
|
||||||
|
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||||
import {
|
import {
|
||||||
classifyConfigHotReloadDiff,
|
createSaveConfigSettingsPatchHandler,
|
||||||
type ConfigHotReloadDiff,
|
type ConfigSettingsHotReloadDiff,
|
||||||
} from '../../core/services/config-hot-reload';
|
} from './config-settings-save';
|
||||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
|
||||||
import {
|
import {
|
||||||
createOpenConfigSettingsWindowHandler,
|
createOpenConfigSettingsWindowHandler,
|
||||||
type ConfigSettingsWindowLike,
|
type ConfigSettingsWindowLike,
|
||||||
@@ -28,6 +29,19 @@ export interface ConfigSettingsIpcChannels {
|
|||||||
saveConfigSettingsPatch: string;
|
saveConfigSettingsPatch: string;
|
||||||
openConfigSettingsFile: string;
|
openConfigSettingsFile: string;
|
||||||
openConfigSettingsWindow: string;
|
openConfigSettingsWindow: string;
|
||||||
|
getConfigSettingsAnkiDeckNames: string;
|
||||||
|
getConfigSettingsAnkiDeckFieldNames: string;
|
||||||
|
getConfigSettingsAnkiDeckModelNames: string;
|
||||||
|
getConfigSettingsAnkiModelNames: string;
|
||||||
|
getConfigSettingsAnkiModelFieldNames: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSettingsAnkiClient {
|
||||||
|
deckNames(): Promise<string[]>;
|
||||||
|
fieldNamesForDeck(deckName: string): Promise<string[]>;
|
||||||
|
modelNamesForDeck(deckName: string): Promise<string[]>;
|
||||||
|
modelNames(): Promise<string[]>;
|
||||||
|
modelFieldNames(modelName: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
||||||
@@ -37,12 +51,14 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
|||||||
getConfig(): ResolvedConfig;
|
getConfig(): ResolvedConfig;
|
||||||
getWarnings(): ConfigValidationWarning[];
|
getWarnings(): ConfigValidationWarning[];
|
||||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||||
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
|
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||||
getSettingsWindow(): TWindow | null;
|
getSettingsWindow(): TWindow | null;
|
||||||
setSettingsWindow(window: TWindow | null): void;
|
setSettingsWindow(window: TWindow | null): void;
|
||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
openPath(path: string): Promise<string>;
|
openPath(path: string): Promise<string>;
|
||||||
|
defaultAnkiConnectUrl: string;
|
||||||
|
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||||
ipcMain: ConfigSettingsIpcMainLike;
|
ipcMain: ConfigSettingsIpcMainLike;
|
||||||
ipcChannels: ConfigSettingsIpcChannels;
|
ipcChannels: ConfigSettingsIpcChannels;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
@@ -111,8 +127,8 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
||||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||||
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
|
|
||||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||||
|
onHotReloadApplied: deps.onHotReloadApplied,
|
||||||
});
|
});
|
||||||
|
|
||||||
function ensureConfigFileExists(): string {
|
function ensureConfigFileExists(): string {
|
||||||
@@ -142,6 +158,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAnkiConnectUrl(draftUrl: unknown): string {
|
||||||
|
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
|
||||||
|
? draftUrl.trim()
|
||||||
|
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAnkiList(
|
||||||
|
draftUrl: unknown,
|
||||||
|
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult> {
|
||||||
|
try {
|
||||||
|
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
|
||||||
|
return { ok: true, values: await lookup(client) };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
values: [],
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
values: [],
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function registerHandlers(): void {
|
function registerHandlers(): void {
|
||||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||||
@@ -155,6 +201,39 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
return openError.length === 0;
|
return openError.length === 0;
|
||||||
});
|
});
|
||||||
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
||||||
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
|
||||||
|
getAnkiList(draftUrl, (client) => client.deckNames()),
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(
|
||||||
|
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
||||||
|
(_event, deckName, draftUrl) => {
|
||||||
|
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
||||||
|
return normalizedDeckName
|
||||||
|
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
|
||||||
|
: invalidAnkiListResult('Deck name is required.');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(
|
||||||
|
deps.ipcChannels.getConfigSettingsAnkiDeckModelNames,
|
||||||
|
(_event, deckName, draftUrl) => {
|
||||||
|
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
||||||
|
return normalizedDeckName
|
||||||
|
? getAnkiList(draftUrl, (client) => client.modelNamesForDeck(normalizedDeckName))
|
||||||
|
: invalidAnkiListResult('Deck name is required.');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
|
||||||
|
getAnkiList(draftUrl, (client) => client.modelNames()),
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(
|
||||||
|
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
|
||||||
|
(_event, modelName, draftUrl) => {
|
||||||
|
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
|
||||||
|
return normalizedModelName
|
||||||
|
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
|
||||||
|
: invalidAnkiListResult('Note type is required.');
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('config settings save applies hot-reloadable diff live', () => {
|
test('config settings save returns hot-reloadable diff for watcher path', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const previous = DEFAULT_CONFIG;
|
const previous = DEFAULT_CONFIG;
|
||||||
const next: ResolvedConfig = {
|
const next: ResolvedConfig = {
|
||||||
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
|
|||||||
hotReloadFields: ['subtitleStyle'],
|
hotReloadFields: ['subtitleStyle'],
|
||||||
restartRequiredFields: [],
|
restartRequiredFields: [],
|
||||||
}),
|
}),
|
||||||
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
|
|
||||||
getRestartRequiredSections: () => [],
|
getRestartRequiredSections: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,11 +61,81 @@ test('config settings save applies hot-reloadable diff live', () => {
|
|||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.match(written, /autoPauseVideoOnHover/);
|
assert.match(written, /autoPauseVideoOnHover/);
|
||||||
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
|
assert.deepEqual(calls, ['write']);
|
||||||
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
||||||
assert.deepEqual(result.restartRequiredFields, []);
|
assert.deepEqual(result.restartRequiredFields, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
|
||||||
|
const previous = DEFAULT_CONFIG;
|
||||||
|
const next: ResolvedConfig = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
subtitleStyle: {
|
||||||
|
...DEFAULT_CONFIG.subtitleStyle,
|
||||||
|
css: {
|
||||||
|
'font-size': '50px',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
...DEFAULT_CONFIG.subtitleStyle.secondary,
|
||||||
|
css: {
|
||||||
|
'font-size': '28px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const applied: Array<{
|
||||||
|
hotReloadFields: string[];
|
||||||
|
config: ResolvedConfig;
|
||||||
|
}> = [];
|
||||||
|
const save = createSaveConfigSettingsPatchHandler({
|
||||||
|
getConfigPath: () => '/tmp/config.jsonc',
|
||||||
|
getCurrentConfig: () => previous,
|
||||||
|
getWarnings: () => [],
|
||||||
|
getSnapshot: () => snapshot(),
|
||||||
|
fileExists: () => true,
|
||||||
|
readText: () => '{}',
|
||||||
|
writeTextAtomically: () => {},
|
||||||
|
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||||
|
ok: true,
|
||||||
|
config: next,
|
||||||
|
warnings: [],
|
||||||
|
path: '/tmp/config.jsonc',
|
||||||
|
}),
|
||||||
|
classifyDiff: () => ({
|
||||||
|
hotReloadFields: ['subtitleStyle'],
|
||||||
|
restartRequiredFields: [],
|
||||||
|
}),
|
||||||
|
getRestartRequiredSections: () => [],
|
||||||
|
onHotReloadApplied: (diff, config) => {
|
||||||
|
applied.push({
|
||||||
|
hotReloadFields: diff.hotReloadFields,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = save({
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'subtitleStyle.css',
|
||||||
|
value: { 'font-size': '50px' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'subtitleStyle.secondary.css',
|
||||||
|
value: { 'font-size': '28px' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(applied.length, 1);
|
||||||
|
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
|
||||||
|
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
|
||||||
|
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
|
||||||
|
});
|
||||||
|
|
||||||
test('config settings save returns restart-required sections without applying hot reload', () => {
|
test('config settings save returns restart-required sections without applying hot reload', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const previous = DEFAULT_CONFIG;
|
const previous = DEFAULT_CONFIG;
|
||||||
@@ -95,7 +164,6 @@ test('config settings save returns restart-required sections without applying ho
|
|||||||
hotReloadFields: [],
|
hotReloadFields: [],
|
||||||
restartRequiredFields: ['mpv'],
|
restartRequiredFields: ['mpv'],
|
||||||
}),
|
}),
|
||||||
applyHotReload: () => calls.push('hot'),
|
|
||||||
getRestartRequiredSections: () => ['mpv launcher'],
|
getRestartRequiredSections: () => ['mpv launcher'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,9 +198,6 @@ test('config settings save restores previous file content when strict reload fai
|
|||||||
classifyDiff: () => {
|
classifyDiff: () => {
|
||||||
throw new Error('Should not classify invalid config.');
|
throw new Error('Should not classify invalid config.');
|
||||||
},
|
},
|
||||||
applyHotReload: () => {
|
|
||||||
throw new Error('Should not hot reload invalid config.');
|
|
||||||
},
|
|
||||||
getRestartRequiredSections: () => [],
|
getRestartRequiredSections: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export interface ConfigSettingsSaveDeps {
|
|||||||
deleteFile?(path: string): void;
|
deleteFile?(path: string): void;
|
||||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||||
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
|
|
||||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||||
|
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
||||||
@@ -64,12 +64,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
|||||||
deps.writeTextAtomically(configPath, candidate.content);
|
deps.writeTextAtomically(configPath, candidate.content);
|
||||||
const reloadResult = deps.reloadConfigStrict();
|
const reloadResult = deps.reloadConfigStrict();
|
||||||
if (!reloadResult.ok) {
|
if (!reloadResult.ok) {
|
||||||
if (hadExistingConfig) {
|
try {
|
||||||
deps.writeTextAtomically(configPath, content);
|
if (hadExistingConfig) {
|
||||||
} else if (deps.deleteFile) {
|
deps.writeTextAtomically(configPath, content);
|
||||||
deps.deleteFile(configPath);
|
} else if (deps.deleteFile) {
|
||||||
} else {
|
deps.deleteFile(configPath);
|
||||||
deps.writeTextAtomically(configPath, content);
|
} else {
|
||||||
|
deps.writeTextAtomically(configPath, content);
|
||||||
|
}
|
||||||
|
deps.reloadConfigStrict();
|
||||||
|
} catch {
|
||||||
|
// Best-effort rollback; preserve original reload error for caller.
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -83,7 +88,7 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
|||||||
|
|
||||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||||
if (diff.hotReloadFields.length > 0) {
|
if (diff.hotReloadFields.length > 0) {
|
||||||
deps.applyHotReload(diff, reloadResult.config);
|
deps.onHotReloadApplied?.(diff, reloadResult.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { SubtitleData } from '../../types';
|
||||||
|
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||||
|
|
||||||
|
function withTiming(payload: SubtitleData): SubtitleData {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.text, '字幕');
|
||||||
|
assert.equal(payload.startTime, 1);
|
||||||
|
assert.deepEqual(payload.tokens, [{ text: '字' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: '',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.text, '');
|
||||||
|
assert.equal(payload.tokens, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: 'まだキャッシュされていない字幕',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||||
|
assert.equal(payload.startTime, 1);
|
||||||
|
assert.equal(payload.tokens, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
|
||||||
|
const payload = await resolveCurrentSubtitleForRenderer({
|
||||||
|
currentSubText: '新しい字幕',
|
||||||
|
currentSubtitleData: null,
|
||||||
|
withCurrentSubtitleTiming: withTiming,
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.text, '新しい字幕');
|
||||||
|
assert.equal(payload.startTime, 1);
|
||||||
|
assert.deepEqual(payload.tokens, [{ text: '新' }]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||||
|
currentSubText: string;
|
||||||
|
currentSubtitleData: SubtitleData | null;
|
||||||
|
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||||
|
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
|
||||||
|
}): Promise<SubtitleData> {
|
||||||
|
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||||
|
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.currentSubText.trim()) {
|
||||||
|
return deps.withCurrentSubtitleTiming({
|
||||||
|
text: deps.currentSubText,
|
||||||
|
tokens: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
|
||||||
|
if (tokenized) {
|
||||||
|
return deps.withCurrentSubtitleTiming(tokenized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deps.withCurrentSubtitleTiming({
|
||||||
|
text: deps.currentSubText,
|
||||||
|
tokens: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user