diff --git a/changes/aniskip-key-learn.md b/changes/aniskip-key-learn.md new file mode 100644 index 00000000..cbfb0b65 --- /dev/null +++ b/changes/aniskip-key-learn.md @@ -0,0 +1 @@ +- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry. diff --git a/changes/mpv-config-unified.md b/changes/mpv-config-unified.md new file mode 100644 index 00000000..05f33011 --- /dev/null +++ b/changes/mpv-config-unified.md @@ -0,0 +1 @@ +feat: manage bundled mpv plugin startup options from SubMiner config diff --git a/changes/note-fields-default-note-type.md b/changes/note-fields-default-note-type.md index 6de3fe73..f9ec42b5 100644 --- a/changes/note-fields-default-note-type.md +++ b/changes/note-fields-default-note-type.md @@ -1,4 +1,4 @@ type: fixed area: config -- Defaulted the note-fields note type picker to `Kiku` when available, then Lapis note types, otherwise leaving it blank for manual selection. +- Defaulted the note-fields note type picker to exact `Kiku` when available, then exact `Lapis`, otherwise leaving it blank for manual selection. diff --git a/changes/overlay-restart-visible.md b/changes/overlay-restart-visible.md new file mode 100644 index 00000000..2be9e94b --- /dev/null +++ b/changes/overlay-restart-visible.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut. diff --git a/changes/subtitle-css-legacy-migration.md b/changes/subtitle-css-legacy-migration.md new file mode 100644 index 00000000..be2b42f6 --- /dev/null +++ b/changes/subtitle-css-legacy-migration.md @@ -0,0 +1,4 @@ +type: fixed +area: config + +- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files. diff --git a/changes/subtitle-css-live-settings.md b/changes/subtitle-css-live-settings.md new file mode 100644 index 00000000..70832c0e --- /dev/null +++ b/changes/subtitle-css-live-settings.md @@ -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. diff --git a/changes/subtitle-sidebar-css.md b/changes/subtitle-sidebar-css.md new file mode 100644 index 00000000..28bbaa70 --- /dev/null +++ b/changes/subtitle-sidebar-css.md @@ -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`. diff --git a/config.example.jsonc b/config.example.jsonc index 0cb0faa7..b7883f0f 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -7,10 +7,11 @@ { // ========================================== - // Overlay Auto-Start - // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. + // Visible Overlay Auto-Start + // 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": false, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false // ========================================== // Texthooker Server @@ -379,6 +380,8 @@ "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. + "paintOrder": "", // Paint order setting. + "WebkitTextStroke": "", // Webkit text stroke setting. "fontStyle": "normal", // Font style setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. @@ -408,7 +411,7 @@ }, // Frequency dictionary setting. "secondary": { "css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. - "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. + "fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "fontSize": 24, // Font size setting. "fontColor": "#cad3f5", // Font color setting. "lineHeight": 1.35, // Line height setting. @@ -417,6 +420,8 @@ "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. + "paintOrder": "", // Paint order setting. + "WebkitTextStroke": "", // Webkit text stroke setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. "fontWeight": "600", // Font weight setting. @@ -436,11 +441,12 @@ "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 "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false + "css": {}, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. "maxWidth": 420, // Maximum sidebar width in CSS pixels. "opacity": 0.95, // Base opacity applied to the sidebar shell. "backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell. "textColor": "#cad3f5", // Default cue text color in the subtitle sidebar. - "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text. + "fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family used for subtitle sidebar cue text. "fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels. "timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar. "activeLineColor": "#f5bde6", // Text color for the active subtitle cue. @@ -599,14 +605,23 @@ // ========================================== // 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. // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. // ========================================== "mpv": { "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 - }, // Optional mpv.exe override for Windows playback entry points. + "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen + "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 diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 0cb0faa7..b7883f0f 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -7,10 +7,11 @@ { // ========================================== - // Overlay Auto-Start - // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. + // Visible Overlay Auto-Start + // 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": false, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false // ========================================== // Texthooker Server @@ -379,6 +380,8 @@ "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. + "paintOrder": "", // Paint order setting. + "WebkitTextStroke": "", // Webkit text stroke setting. "fontStyle": "normal", // Font style setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. @@ -408,7 +411,7 @@ }, // Frequency dictionary setting. "secondary": { "css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. - "fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting. + "fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "fontSize": 24, // Font size setting. "fontColor": "#cad3f5", // Font color setting. "lineHeight": 1.35, // Line height setting. @@ -417,6 +420,8 @@ "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. + "paintOrder": "", // Paint order setting. + "WebkitTextStroke": "", // Webkit text stroke setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. "fontWeight": "600", // Font weight setting. @@ -436,11 +441,12 @@ "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 "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false + "css": {}, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties. "maxWidth": 420, // Maximum sidebar width in CSS pixels. "opacity": 0.95, // Base opacity applied to the sidebar shell. "backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell. "textColor": "#cad3f5", // Default cue text color in the subtitle sidebar. - "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text. + "fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family used for subtitle sidebar cue text. "fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels. "timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar. "activeLineColor": "#f5bde6", // Text color for the active subtitle cue. @@ -599,14 +605,23 @@ // ========================================== // 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. // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. // ========================================== "mpv": { "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 - }, // Optional mpv.exe override for Windows playback entry points. + "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen + "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 diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 3dee9dde..68aa0816 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -567,9 +567,11 @@ export function buildSubminerScriptOpts( logLevel: LogLevel = 'info', extraParts: string[] = [], ): string { + const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path=')); + const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path=')); const parts = [ - `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, - `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, + ...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]), + ...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]), ...extraParts.map(sanitizeScriptOptValue), ]; if (logLevel !== 'info') { diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index a7b3cfbc..257ea20f 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -38,9 +38,14 @@ function createContext(overrides: Partial = {}): Launche mpvSocketPath: '/tmp/subminer.sock', pluginRuntimeConfig: { socketPath: '/tmp/subminer.sock', + binaryPath: '', + backend: 'auto', autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', }, appPath: '/tmp/subminer.app', launcherJellyfinConfig: {}, diff --git a/launcher/commands/mpv-command.ts b/launcher/commands/mpv-command.ts index 597f44fc..65ad01d7 100644 --- a/launcher/commands/mpv-command.ts +++ b/launcher/commands/mpv-command.ts @@ -13,6 +13,7 @@ interface MpvCommandDeps { appPath: string, args: LauncherCommandContext['args'], runtimePluginPath?: string | null, + runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'], ): Promise; } @@ -49,7 +50,7 @@ export async function runMpvPostAppCommand( context: LauncherCommandContext, deps: MpvCommandDeps = defaultDeps, ): Promise { - const { args, appPath, scriptPath, mpvSocketPath } = context; + const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context; if (!args.mpvIdle) { return false; } @@ -62,6 +63,11 @@ export async function runMpvPostAppCommand( appPath, args, resolveLauncherRuntimePluginPath({ appPath, scriptPath }), + { + ...pluginRuntimeConfig, + backend: args.backend, + texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled, + }, ); const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000); if (!ready) { diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index b01d9d48..6821ba54 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -72,9 +72,14 @@ function createContext(): LauncherCommandContext { mpvSocketPath: '/tmp/subminer.sock', pluginRuntimeConfig: { socketPath: '/tmp/subminer.sock', + binaryPath: '', + backend: 'auto', autoStart: true, autoStartVisibleOverlay: true, autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', }, appPath: '/tmp/SubMiner.AppImage', launcherJellyfinConfig: {}, @@ -140,7 +145,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async 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(); context.args = { ...context.args, @@ -149,9 +154,14 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits }; context.pluginRuntimeConfig = { socketPath: '/tmp/subminer.sock', + binaryPath: '', + backend: 'auto', autoStart: true, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', }; const appPath = context.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.killed = false; mpvProc.kill = () => true; - let cleanupSawManagedOverlay = false; + let cleanupSawManagedOverlay = true; try { await runPlaybackCommandWithDeps(context, { @@ -190,7 +200,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits getMpvProc: () => mpvProc as NonNullable, }); - assert.equal(cleanupSawManagedOverlay, true); + assert.equal(cleanupSawManagedOverlay, false); } finally { state.appPath = ''; state.overlayManagedByLauncher = false; diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 7e92826b..e45ed7d9 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -7,7 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; import { cleanupPlaybackSession, launchAppCommandDetached, - markOverlayManagedByLauncher, resolveLauncherRuntimePluginPath, startMpv, startOverlay, @@ -238,6 +237,11 @@ export async function runPlaybackCommandWithDeps( startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow, disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow, 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) { - markOverlayManagedByLauncher(appPath); if (ready) { deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); } else { diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index c222102c..5712839a 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; import { parseLauncherMpvConfig } from './config/mpv-config.js'; import { readExternalYomitanProfilePath } from './config.js'; import { - getPluginConfigCandidates, - parsePluginRuntimeConfigContent, + buildPluginRuntimeScriptOptParts, + parsePluginRuntimeConfigFromMainConfig, } from './config/plugin-runtime-config.js'; import { getDefaultSocketPath } from './types.js'; @@ -86,10 +86,24 @@ test('parseLauncherMpvConfig reads launch mode preference', () => { mpv: { launchMode: ' maximized ', 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.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', () => { @@ -102,39 +116,72 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => { assert.equal(parsed.launchMode, undefined); }); -test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => { - const parsed = parsePluginRuntimeConfigContent(` -# comment -socket_path = /tmp/custom.sock # trailing comment -auto_start = yes -auto_start_visible_overlay = true -auto_start_pause_until_ready = 1 -`); - assert.equal(parsed.socketPath, '/tmp/custom.sock'); +test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => { + const parsed = parsePluginRuntimeConfigFromMainConfig({ + auto_start_overlay: false, + texthooker: { + launchAtStartup: false, + }, + mpv: { + socketPath: '/tmp/config.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.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.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( - getPluginConfigCandidates({ - platform: 'win32', - homeDir: 'C:\\Users\\tester', - appDataDir: 'C:\\Users\\tester\\AppData\\Roaming', - }), - ['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'], + buildPluginRuntimeScriptOptParts( + { + socketPath: '/tmp/config.sock', + binaryPath: '/opt/SubMiner/SubMiner.AppImage', + backend: 'x11', + 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', + ], ); }); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 4fa26131..c06eb159 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -118,7 +118,7 @@ export function createDefaultArgs( const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]); const parsed: Args = { - backend: 'auto', + backend: mpvConfig.backend ?? 'auto', directory: '.', recursive: false, profile: '', diff --git a/launcher/config/mpv-config.ts b/launcher/config/mpv-config.ts index 2bcbcdb1..a57dd2ab 100644 --- a/launcher/config/mpv-config.ts +++ b/launcher/config/mpv-config.ts @@ -1,6 +1,29 @@ import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js'; +import type { Backend } 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): LauncherMpvConfig { const mpvRaw = root.mpv; if (!mpvRaw || typeof mpvRaw !== 'object') return {}; @@ -8,5 +31,15 @@ export function parseLauncherMpvConfig(root: Record): LauncherM return { 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), }; } diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index 64498a14..5c759da2 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -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 type { LogLevel, PluginRuntimeConfig } from '../types.js'; +import type { Backend, LogLevel, PluginRuntimeConfig } 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 { - return platform === 'win32' ? path.win32 : path.posix; +function rootObject(root: Record | null, key: string): Record { + const value = root?.[key]; + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; } -export function getPluginConfigCandidates(options?: { - platform?: NodeJS.Platform; - homeDir?: string; - xdgConfigHome?: string; - appDataDir?: string; -}): string[] { - const platform = options?.platform ?? process.platform; - const homeDir = options?.homeDir ?? os.homedir(); - const platformPath = getPlatformPath(platform); +function booleanOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} - if (platform === 'win32') { - const appDataDir = - options?.appDataDir?.trim() || - process.env.APPDATA?.trim() || - platformPath.join(homeDir, 'AppData', 'Roaming'); - return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')]; +function nonEmptyStringOrDefault(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +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; } - - 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'), - ]), - ); + return fallback; } -export function parsePluginRuntimeConfigContent( - content: string, - logLevel: LogLevel = 'warn', +export function parsePluginRuntimeConfigFromMainConfig( + root: Record | null, ): PluginRuntimeConfig { - const runtimeConfig: PluginRuntimeConfig = { - socketPath: DEFAULT_SOCKET_PATH, - autoStart: true, - autoStartVisibleOverlay: true, - autoStartPauseUntilReady: true, + const mpvConfig = root ? parseLauncherMpvConfig(root) : {}; + const texthooker = rootObject(root, 'texthooker'); + + return { + 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 => { - const normalized = value.trim().toLowerCase(); - if (['yes', 'true', '1', 'on'].includes(normalized)) return true; - if (['no', 'false', '0', 'off'].includes(normalized)) return false; - log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`); - 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 buildPluginRuntimeScriptOptParts( + runtimeConfig: PluginRuntimeConfig, + fallbackAppPath: string, +): string[] { + return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath); } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { - const candidates = getPluginConfigCandidates(); - 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; - } - } + const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject()); log( 'debug', 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; } diff --git a/launcher/main.test.ts b/launcher/main.test.ts index cbb66f38..0764f523 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -157,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () => const homeDir = path.join(root, 'home'); const xdgConfigHome = path.join(root, 'xdg'); 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( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${expectedSocket}\n`, + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + JSON.stringify({ mpv: { socketPath: expectedSocket } }), ); 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 xdgConfigHome = path.join(root, 'xdg'); 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( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\n`, + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + JSON.stringify({ mpv: { socketPath } }), ); const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome)); @@ -321,7 +321,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); fs.writeFileSync(videoPath, 'fake video content'); fs.writeFileSync( path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), @@ -336,8 +336,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () }), ); fs.writeFileSync( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`, + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + JSON.stringify({ + auto_start_overlay: false, + mpv: { + socketPath, + autoStartSubMiner: false, + pauseUntilOverlayReady: false, + }, + }), ); fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.chmodSync(appPath, 0o755); @@ -401,7 +408,7 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); fs.writeFileSync(videoPath, 'fake video content'); fs.writeFileSync( path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), @@ -416,8 +423,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo }), ); fs.writeFileSync( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`, + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + JSON.stringify({ + auto_start_overlay: true, + mpv: { + socketPath, + autoStartSubMiner: true, + pauseUntilOverlayReady: true, + }, + }), ); fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); fs.chmodSync(appPath, 0o755); @@ -471,7 +485,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout: fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); fs.writeFileSync( path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), JSON.stringify({ @@ -485,8 +499,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout: }), ); fs.writeFileSync( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`, + path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + JSON.stringify({ + auto_start_overlay: true, + mpv: { + socketPath, + autoStartSubMiner: true, + pauseUntilOverlayReady: true, + }, + }), ); fs.writeFileSync( appPath, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 58b4cd8d..af4b54f0 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -8,10 +8,11 @@ import { detectInstalledMpvPlugin, type InstalledMpvPluginDetection, } 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 { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; +import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js'; import { nowMs } from './time.js'; import { commandExists, @@ -849,6 +850,7 @@ export async function startMpv( startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean; runtimePluginPath?: string | null; + runtimePluginConfig?: PluginRuntimeConfig; }, ): Promise { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { @@ -916,13 +918,13 @@ export async function startMpv( options?.disableYoutubeSubtitleAutoLoad === true ? ['subminer-auto_start_pause_until_ready=no'] : []; - const scriptOpts = buildSubminerScriptOpts( - appPath, - socketPath, - aniSkipMetadata, - args.logLevel, - extraScriptOpts, - ); + const runtimeScriptOpts = options?.runtimePluginConfig + ? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath) + : [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`]; + const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [ + ...runtimeScriptOpts, + ...extraScriptOpts, + ]); if (aniSkipMetadata) { log( 'debug', @@ -1477,6 +1479,7 @@ export function launchMpvIdleDetached( appPath: string, args: Args, runtimePluginPath?: string | null, + runtimePluginConfig?: PluginRuntimeConfig, ): Promise { return (async () => { await terminateTrackedDetachedMpv(args.logLevel); @@ -1498,8 +1501,17 @@ export function launchMpvIdleDetached( mpvArgs.push(...parseMpvArgString(args.mpvArgs)); } mpvArgs.push('--idle=yes'); + const runtimeScriptOpts = runtimePluginConfig + ? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath) + : [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`]; 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(`--input-ipc-server=${socketPath}`); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 439e9955..260bcd8c 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -58,14 +58,11 @@ function createSmokeCase(name: string): SmokeCase { fs.mkdirSync(artifactsDir, { 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( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\n`, - ); const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir }); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } })); const setupState = createDefaultSetupState(); setupState.status = 'completed'; setupState.completedAt = '2026-03-07T00:00:00.000Z'; @@ -356,14 +353,15 @@ test( async () => { await withSmokeCase('autoplay-ready-gate', async (smokeCase) => { fs.writeFileSync( - path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - [ - `socket_path=${smokeCase.socketPath}`, - 'auto_start=yes', - 'auto_start_visible_overlay=yes', - 'auto_start_pause_until_ready=yes', - '', - ].join('\n'), + path.join(getDefaultConfigDir(smokeCase), 'config.jsonc'), + JSON.stringify({ + auto_start_overlay: true, + mpv: { + socketPath: smokeCase.socketPath, + autoStartSubMiner: true, + pauseUntilOverlayReady: true, + }, + }), ); const env = makeTestEnv(smokeCase); diff --git a/launcher/types.ts b/launcher/types.ts index 9751a157..dc6df390 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -1,15 +1,13 @@ import path from 'node:path'; 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 { getDefaultMpvSocketPath } from '../src/shared/mpv-socket-path.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export const ROFI_THEME_FILE = 'subminer.rasi'; export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string { - if (platform === 'win32') { - return '\\\\.\\pipe\\subminer-socket'; - } - return '/tmp/subminer-socket'; + return getDefaultMpvSocketPath(platform); } export const DEFAULT_SOCKET_PATH = getDefaultSocketPath(); @@ -178,13 +176,25 @@ export interface LauncherJellyfinConfig { export interface LauncherMpvConfig { launchMode?: MpvLaunchMode; + socketPath?: string; + backend?: MpvBackend; + autoStartSubMiner?: boolean; + pauseUntilOverlayReady?: boolean; + subminerBinaryPath?: string; + aniskipEnabled?: boolean; + aniskipButtonKey?: string; } export interface PluginRuntimeConfig { socketPath: string; + binaryPath: string; + backend: Backend; autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; + texthookerEnabled: boolean; + aniskipEnabled: boolean; + aniskipButtonKey: string; } export interface CommandExecOptions { diff --git a/main.js b/main.js index c3c6c962..b75a69d6 100644 --- a/main.js +++ b/main.js @@ -113,6 +113,7 @@ const windows_helper_1 = require("./window-trackers/windows-helper"); const args_1 = require("./cli/args"); const help_1 = require("./cli/help"); 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 config_validation_1 = require("./main/config-validation"); 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_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_subtitle_snapshot_1 = require("./main/runtime/current-subtitle-snapshot"); const startup_osd_sequencer_1 = require("./main/runtime/startup-osd-sequencer"); const app_updater_1 = require("./main/runtime/update/app-updater"); 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_metadata_policy_1 = require("./main/runtime/update/release-metadata-policy"); const launcher_updater_1 = require("./main/runtime/update/launcher-updater"); const update_notifications_1 = require("./main/runtime/update/update-notifications"); 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 subtitle_prefetch_runtime_1 = require("./main/runtime/subtitle-prefetch-runtime"); 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_save_1 = require("./main/runtime/config-settings-save"); +const config_settings_runtime_1 = require("./main/runtime/config-settings-runtime"); const youtube_playback_1 = require("./main/runtime/youtube-playback"); const yomitan_profile_policy_1 = require("./main/runtime/yomitan-profile-policy"); 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 config_2 = require("./config"); 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 subtitle_cue_parser_1 = require("./core/services/subtitle-cue-parser"); const subtitle_prefetch_1 = require("./core/services/subtitle-prefetch"); @@ -293,7 +295,7 @@ const texthookerService = new services_1.Texthooker(() => { const config = getResolvedConfig(); const characterDictionaryEnabled = config.anilist.characterDictionary.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); - const knownAndNPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled); + const knownAndNPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled); return { enableKnownWordColoring: knownAndNPlusOneEnabled, enableNPlusOneColoring: knownAndNPlusOneEnabled, @@ -301,8 +303,8 @@ const texthookerService = new services_1.Texthooker(() => { enableFrequencyColoring: getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled), enableJlptColoring: getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt), characterDictionaryEnabled, - knownWordColor: config.ankiConnect.knownWords.color, - nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, + knownWordColor: config.subtitleStyle.knownWordColor, + nPlusOneColor: config.subtitleStyle.nPlusOneColor, nameMatchColor: config.subtitleStyle.nameMatchColor, hoverTokenColor: config.subtitleStyle.hoverTokenColor, hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor, @@ -732,6 +734,16 @@ const youtubePlaybackRuntime = (0, youtube_playback_runtime_1.createYoutubePlayb detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin, notifyInstalledPluginDetected: logInstalledMpvPluginDetected, 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), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), @@ -756,12 +768,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({ resourcesPath: process.resourcesPath, 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)({ platform: process.platform, configDir: CONFIG_DIR, @@ -1233,80 +1239,15 @@ const buildConfigHotReloadRuntimeMainDepsHandler = (0, overlay_1.createBuildConf }, }); const configHotReloadRuntime = (0, services_1.createConfigHotReloadRuntime)(buildConfigHotReloadRuntimeMainDepsHandler()); -function getConfigSettingsSnapshot() { - return (0, jsonc_edit_1.buildConfigSettingsSnapshot)({ - 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)({ +const configSettingsRuntime = (0, config_settings_runtime_1.createConfigSettingsRuntime)({ + fields: configSettingsFields, getConfigPath: () => configService.getConfigPath(), - getCurrentConfig: () => configService.getConfig(), + getRawConfig: () => configService.getRawConfig(), + getConfig: () => configService.getConfig(), 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(), - classifyDiff: (previous, next) => (0, services_1.classifyConfigHotReloadDiff)(previous, next), - applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config), - 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)({ + defaultAnkiConnectUrl: config_2.DEFAULT_CONFIG.ankiConnect.url, + createAnkiClient: (url) => new anki_connect_1.AnkiConnectClient(url), getSettingsWindow: () => appState.configSettingsWindow, setSettingsWindow: (window) => { appState.configSettingsWindow = window; @@ -1316,26 +1257,13 @@ const openConfigSettingsWindow = (0, config_settings_window_1.createOpenConfigSe preloadPath: path.join(__dirname, 'preload-settings.js'), }), 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()); -electron_1.ipcMain.handle(contracts_1.IPC_CHANNELS.request.saveConfigSettingsPatch, (_event, patch) => { - 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()); +configSettingsRuntime.registerHandlers(); +const openConfigSettingsWindow = () => configSettingsRuntime.openWindow(); const buildDictionaryRootsHandler = (0, startup_1.createBuildDictionaryRootsMainHandler)({ platform: process.platform, dirname: __dirname, @@ -1891,7 +1819,7 @@ function getRuntimeBooleanOption(id, fallback) { } function shouldInitializeMecabForAnnotations() { const config = getResolvedConfig(); - const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled); + const nPlusOneEnabled = getRuntimeBooleanOption('subtitle.annotation.nPlusOne', config.ankiConnect.nPlusOne.enabled); const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt); const frequencyEnabled = getRuntimeBooleanOption('subtitle.annotation.frequency', config.subtitleStyle.frequencyDictionary.enabled); return nPlusOneEnabled || jlptEnabled || frequencyEnabled; @@ -1916,6 +1844,17 @@ const { getResolvedJellyfinConfig, reportJellyfinRemoteProgress, reportJellyfinR getLaunchMode: () => getResolvedConfig().mpv.launchMode, platform: process.platform, 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, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, removeSocketPath: (socketPath) => { @@ -3114,6 +3053,17 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd 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), recordAnilistMediaDuration: (durationSec) => { recordAnilistMediaDuration(durationSec); @@ -3272,7 +3222,7 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, upd }, getKnownWordMatchMode: () => appState.ankiIntegration?.getKnownWordMatchMode() ?? 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, getJlptLevel: (text) => appState.jlptLevelLookup(text), getJlptEnabled: () => getRuntimeBooleanOption('subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt), @@ -3698,6 +3648,8 @@ function getUpdateService() { isPackaged: electron_1.app.isPackaged, log: (message) => logger.info(message), 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)({ platform: process.platform, isPackaged: electron_1.app.isPackaged, @@ -3718,7 +3670,7 @@ function getUpdateService() { readState: () => updateStateStore.readState(), writeState: (state) => updateStateStore.writeState(state), 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 }), updateLauncher: (launcherPath, channel, release) => updateLauncherFromSelectedRelease(launcherPath, channel, release), showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), @@ -4083,7 +4035,11 @@ const { registerIpcRuntimeHandlers } = (0, composers_1.composeIpcRuntimeHandlers openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), 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, getCurrentSubtitleAss: () => appState.currentSubAssText, getSubtitleSidebarSnapshot: async () => { @@ -4387,7 +4343,7 @@ const { runAndApplyStartupState } = (0, composers_1.composeHeadlessStartupHandle (0, utils_2.enforceUnsupportedWaylandMode)(args); }, shouldStartApp: (args) => (0, args_1.shouldStartApp)(args), - getDefaultSocketPath: () => getDefaultSocketPath(), + getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, configDir: CONFIG_DIR, defaultConfig: config_2.DEFAULT_CONFIG, @@ -4441,7 +4397,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => (0, services_1.sendMpvCommandRuntime)(appState.mpvClient, ['keypress', 'TAB']), onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), - onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + onWindowContentReady: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + if (appState.currentSubText.trim()) { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + } + }, onWindowClosed: (windowKind) => { if (windowKind === 'visible') { cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); diff --git a/package.json b/package.json index db5217b8..03b23c64 100644 --- a/package.json +++ b/package.json @@ -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: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:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.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:dist": "bun test dist/settings/settings-anki-controls.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/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:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.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/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/settings/settings-anki-controls.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: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", diff --git a/plugin/subminer.conf b/plugin/subminer.conf index efaa06ef..bd6c1661 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -1,75 +1,3 @@ -# SubMiner configuration -# Place this file in ~/.config/mpv/script-opts/ - -# 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 +# SubMiner managed playback config lives in SubMiner config.jsonc. +# This file is intentionally empty so installed/default mpv script-opts do not +# override the app config modal or generated config file. diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index f084314b..e0b0a736 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -27,16 +27,16 @@ function M.load(options_lib, default_socket_path) local opts = { binary_path = "", socket_path = default_socket_path, - texthooker_enabled = true, + texthooker_enabled = false, texthooker_port = 5174, backend = "auto", - auto_start = true, - auto_start_visible_overlay = true, + auto_start = false, + auto_start_visible_overlay = false, auto_start_pause_until_ready = true, auto_start_pause_until_ready_timeout_seconds = 15, osd_messages = true, log_level = "info", - aniskip_enabled = true, + aniskip_enabled = false, aniskip_title = "", aniskip_season = "", aniskip_mal_id = "", diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index ab73799f..d24e4a70 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -199,7 +199,10 @@ function M.create(ctx) table.insert(args, "--socket") 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 table.insert(args, "--show-visible-overlay") else @@ -506,7 +509,9 @@ function M.create(ctx) state.texthooker_running = false disarm_auto_play_ready_gate() - local start_args = build_command_args("start") + 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 diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 0656e777..dd7128af 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -559,6 +559,40 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + 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") + 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" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -567,6 +601,7 @@ do 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", @@ -608,6 +643,7 @@ do option_overrides = { binary_path = binary_path, auto_start = "no", + aniskip_enabled = "yes", }, files = { [binary_path] = true, @@ -644,6 +680,7 @@ do 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", @@ -682,6 +719,7 @@ do auto_start = "yes", auto_start_visible_overlay = "yes", auto_start_pause_until_ready = "no", + texthooker_enabled = "yes", socket_path = "/tmp/subminer-socket", }, input_ipc_server = "/tmp/subminer-socket", @@ -737,6 +775,7 @@ do option_overrides = { binary_path = binary_path, auto_start = "no", + aniskip_enabled = "yes", }, media_title = "Random Movie", files = { @@ -765,6 +804,7 @@ do auto_start = "yes", auto_start_visible_overlay = "yes", auto_start_pause_until_ready = "no", + texthooker_enabled = "yes", socket_path = "/tmp/subminer-socket", }, input_ipc_server = "/tmp/subminer-socket", @@ -793,6 +833,7 @@ do option_overrides = { binary_path = binary_path, auto_start = "no", + aniskip_enabled = "yes", }, media_title = "Sample Show S01E01", mal_lookup_stdout = "__MAL_FOUND__", @@ -818,6 +859,7 @@ do option_overrides = { binary_path = binary_path, auto_start = "no", + aniskip_enabled = "yes", }, media_title = "Sample Show S01E01", time_pos = 13, @@ -852,6 +894,7 @@ do auto_start = "yes", auto_start_visible_overlay = "yes", auto_start_pause_until_ready = "no", + texthooker_enabled = "yes", socket_path = "/tmp/subminer-socket", }, input_ipc_server = "/tmp/subminer-socket", @@ -1236,6 +1279,27 @@ do ) 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 local recorded, err = run_plugin_scenario({ platform = "windows", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index caf9960f..d500c220 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -15,7 +15,7 @@ import { generateConfigTemplate } from './template'; const DEFAULT_SUBTITLE_FONT_FAMILY = '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)'; function makeTempDir(): string { @@ -83,13 +83,19 @@ test('loads defaults when config is missing', () => { assert.equal(config.subtitleStyle.fontKerning, 'normal'); assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision'); 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.jlptColors.N4, '#8bd5ca'); assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY); assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5'); assert.equal(config.subtitleStyle.secondary.fontWeight, '600'); 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.deepEqual(config.subtitleSidebar.css, {}); + assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY); assert.equal(config.immersionTracking.enabled, true); assert.equal(config.immersionTracking.dbPath, ''); assert.equal(config.immersionTracking.batchSize, 25); @@ -113,6 +119,13 @@ test('loads defaults when config is missing', () => { assert.equal(config.updates.checkIntervalHours, 24); assert.equal(config.updates.notificationType, 'system'); 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', () => { @@ -181,6 +194,86 @@ 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", + "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; + css?: Record; + secondary?: { + fontSize?: unknown; + fontColor?: unknown; + css?: Record; + }; + }; + subtitleSidebar: { + fontFamily?: unknown; + fontSize?: unknown; + textColor?: unknown; + timestampColor?: unknown; + css?: Record; + }; + }; + + assert.deepEqual(parsed.subtitleStyle.css, { + color: '#ffffff', + 'font-size': '44px', + 'text-wrap': 'balance', + }); + assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false); + assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), 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', () => { const validDir = makeTempDir(); fs.writeFileSync( @@ -255,6 +348,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', () => { const validDir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index f878da77..81e1e134 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -1,4 +1,5 @@ import { ResolvedConfig } from '../../types/config'; +import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, @@ -93,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< mpv: { executablePath: '', launchMode: 'normal', + socketPath: getDefaultMpvSocketPath(), + backend: 'auto', + autoStartSubMiner: true, + pauseUntilOverlayReady: true, + subminerBinaryPath: '', + aniskipEnabled: true, + aniskipButtonKey: 'TAB', }, anilist: { enabled: false, diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index b3674e7c..05dceade 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -22,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick = new Set([ 'subtitleStyle.jlptColors.N5', 'subtitleStyle.letterSpacing', 'subtitleStyle.lineHeight', + 'subtitleStyle.paintOrder', 'subtitleStyle.secondary.backdropFilter', 'subtitleStyle.secondary.backgroundColor', 'subtitleStyle.secondary.fontColor', @@ -75,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([ 'subtitleStyle.secondary.fontWeight', 'subtitleStyle.secondary.letterSpacing', 'subtitleStyle.secondary.lineHeight', + 'subtitleStyle.secondary.paintOrder', 'subtitleStyle.secondary.textRendering', 'subtitleStyle.secondary.textShadow', + 'subtitleStyle.secondary.WebkitTextStroke', 'subtitleStyle.secondary.wordSpacing', 'subtitleStyle.textRendering', 'subtitleStyle.textShadow', + 'subtitleStyle.WebkitTextStroke', 'subtitleStyle.wordSpacing', ]); @@ -101,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', () 'anilist.characterDictionary.collapsibleSections.description', 'mpv.executablePath', 'mpv.launchMode', + 'mpv.socketPath', + 'mpv.backend', + 'mpv.autoStartSubMiner', + 'mpv.pauseUntilOverlayReady', + 'mpv.subminerBinaryPath', + 'mpv.aniskipEnabled', + 'mpv.aniskipButtonKey', 'yomitan.externalProfilePath', 'immersionTracking.enabled', ]) { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index a5454379..381936b0 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry( path: 'auto_start_overlay', kind: 'boolean', 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', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 0d15665f..e875eb88 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -282,7 +282,8 @@ export function buildIntegrationConfigOptionRegistry( 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.', + description: + 'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.', }, { path: 'ankiConnect.nPlusOne.minSentenceWords', @@ -448,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.mpv.launchMode, 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', kind: 'boolean', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index fc8cbd1b..04a48f70 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -181,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry( defaultValue: defaultConfig.subtitleSidebar.autoScroll, 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', kind: 'number', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 010db4bd..eaa7682b 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared'; const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { - title: 'Overlay Auto-Start', + title: 'Visible Overlay Auto-Start', 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', }, @@ -166,7 +167,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'MPV Launcher', 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.', 'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.', ], diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index aed83436..41d22899 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void { `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) { warn('mpv', src.mpv, resolved.mpv, 'Expected object.'); } diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index c95794be..4f18c0aa 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -521,6 +521,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ...(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); if (enabled !== undefined) { resolved.subtitleSidebar.enabled = enabled; diff --git a/src/config/resolve/subtitle-sidebar.test.ts b/src/config/resolve/subtitle-sidebar.test.ts index b7cb6fb7..1853377f 100644 --- a/src/config/resolve/subtitle-sidebar.test.ts +++ b/src/config/resolve/subtitle-sidebar.test.ts @@ -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', () => { const { context, warnings } = createResolveContext({ subtitleSidebar: { diff --git a/src/config/service.ts b/src/config/service.ts index 4d76dc02..5442d165 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -4,6 +4,7 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions'; import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load'; import { resolveConfig } from './resolve'; +import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration'; export type ReloadConfigStrictResult = | { @@ -49,7 +50,10 @@ export class ConfigService { if (!loadResult.ok) { throw new ConfigStartupParseError(loadResult.path, loadResult.error); } - this.applyResolvedConfig(loadResult.config, loadResult.path); + this.applyResolvedConfig( + this.migrateLegacySubtitleStyleCssConfig(loadResult.config, loadResult.path), + loadResult.path, + ); } getConfigPath(): string { @@ -70,7 +74,10 @@ export class ConfigService { reloadConfig(): ResolvedConfig { const { config, path: configPath } = loadRawConfig(this.configPaths); - return this.applyResolvedConfig(config, configPath); + return this.applyResolvedConfig( + this.migrateLegacySubtitleStyleCssConfig(config, configPath), + configPath, + ); } reloadConfigStrict(): ReloadConfigStrictResult { @@ -80,7 +87,10 @@ export class ConfigService { } const { config, path: configPath } = loadResult; - const resolvedConfig = this.applyResolvedConfig(config, configPath); + const resolvedConfig = this.applyResolvedConfig( + this.migrateLegacySubtitleStyleCssConfig(config, configPath), + configPath, + ); return { ok: true, config: resolvedConfig, @@ -113,4 +123,25 @@ export class ConfigService { this.warnings = warnings; return this.getConfig(); } + + private migrateLegacySubtitleStyleCssConfig(config: RawConfig, configPath: string): RawConfig { + if (!fs.existsSync(configPath)) { + return config; + } + + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const migration = applyLegacySubtitleStyleCssMigrationToContent({ + content, + rawConfig: config, + }); + if (!migration.migrated) { + return config; + } + fs.writeFileSync(configPath, migration.content, 'utf-8'); + return migration.rawConfig; + } catch { + return config; + } + } } diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 1d74b8a8..07ad2def 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -17,6 +17,17 @@ test('settings registry splits viewing into appearance and behavior categories', 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'), + ); }); test('settings registry groups annotation display fields by config group', () => { @@ -40,12 +51,19 @@ test('settings registry routes known words sync, n+1, and frequency config to be 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.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'); @@ -54,6 +72,8 @@ test('settings registry exposes specialized controls for config-assisted inputs' 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'); }); @@ -80,6 +100,42 @@ test('settings registry exposes css declaration editor for primary and secondary 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', () => { @@ -89,10 +145,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') < ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'), ); - - const kikuLapis = fields.filter( - (candidate) => candidate.section === 'Kiku/Lapis Features', + assert.ok( + fields.findIndex((candidate) => candidate.section === 'AnkiConnect') < + fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'), ); + + const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features'); assert.deepEqual( kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'], diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 53f227a9..93198ebb 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -84,6 +84,7 @@ const JSON_OBJECT_FIELDS = new Set([ 'ankiConnect.knownWords.decks', 'subtitleStyle.css', 'subtitleStyle.secondary.css', + 'subtitleSidebar.css', ]); const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']); @@ -92,8 +93,7 @@ const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColo const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([ ...getSubtitleCssManagedConfigPaths('primary'), ...getSubtitleCssManagedConfigPaths('secondary'), - 'subtitleStyle.hoverTokenColor', - 'subtitleStyle.hoverTokenBackgroundColor', + ...getSubtitleCssManagedConfigPaths('sidebar'), ]); const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry])); @@ -102,7 +102,6 @@ const CATEGORY_ORDER: ConfigSettingsCategory[] = [ 'appearance', 'behavior', 'mining-anki', - 'playback-sources', 'input', 'integrations', 'tracking-app', @@ -118,12 +117,17 @@ const SECTION_ORDER = new Map( '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 Proxy', 'AnkiConnect', + 'AnkiConnect Proxy', + 'Jimaku', + 'Subtitle Sync', 'MPV Keybindings', 'Overlay Shortcuts', 'Controller', @@ -142,11 +146,23 @@ const PATH_ORDER = new Map( '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]), ); @@ -177,10 +193,19 @@ const LABEL_OVERRIDES: Record = { '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 = { @@ -196,6 +221,8 @@ const DESCRIPTION_OVERRIDES: Record = { '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 { @@ -337,14 +364,17 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('ankiConnect.')) { return { category: 'mining-anki', section: 'AnkiConnect' }; } - if ( - path.startsWith('mpv.') || - path.startsWith('youtube.') || - path.startsWith('youtubeSubgen.') || - path.startsWith('jimaku.') || - path.startsWith('subsync.') - ) { - return { category: 'playback-sources', section: topSection(path) }; + if (path === 'auto_start_overlay') { + return { category: 'behavior', section: topSection(path) }; + } + if (path.startsWith('mpv.') || path.startsWith('youtube.')) { + return { category: 'behavior', section: topSection(path) }; + } + if (path.startsWith('jimaku.')) { + 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' }; @@ -380,8 +410,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s path.startsWith('stats.') || path.startsWith('updates.') || path.startsWith('startupWarmups.') || - path.startsWith('logging.') || - path === 'auto_start_overlay' + path.startsWith('logging.') ) { return { category: 'tracking-app', section: topSection(path) }; } @@ -399,17 +428,17 @@ function topSection(path: string): string { jimaku: 'Jimaku', jellyfin: 'Jellyfin', logging: 'Logging', - mpv: 'mpv launcher', + mpv: 'MPV Launcher', stats: 'Stats dashboard', startupWarmups: 'Startup warmups', - subsync: 'Auto subtitle sync', + subsync: 'Subtitle Sync', texthooker: 'Texthooker', updates: 'Updates', websocket: 'WebSocket server', yomitan: 'Yomitan', - youtube: 'YouTube playback', + youtube: 'YouTube Playback Settings', youtubeSubgen: 'YouTube subtitle generation', - auto_start_overlay: 'Overlay startup', + auto_start_overlay: 'Visible Overlay Auto-Start', }; return labels[top] ?? humanizePath(top); } @@ -423,6 +452,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl { 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' || @@ -543,8 +573,14 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number { 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 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; diff --git a/src/config/subtitle-style-css-migration.ts b/src/config/subtitle-style-css-migration.ts new file mode 100644 index 00000000..3d01b66c --- /dev/null +++ b/src/config/subtitle-style-css-migration.ts @@ -0,0 +1,121 @@ +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 STARTUP_MIGRATION_EXCLUDED_PATHS = new Set([ + 'subtitleStyle.hoverTokenColor', + 'subtitleStyle.hoverTokenBackgroundColor', +]); + +export type LegacySubtitleStyleCssMigrationResult = + | { + migrated: true; + content: string; + rawConfig: RawConfig; + } + | { + migrated: false; + content: string; + rawConfig: RawConfig; + }; + +function isRecord(value: unknown): value is Record { + 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; +} + +export function buildLegacySubtitleStyleCssMigrationOperations( + rawConfig: RawConfig, +): ConfigSettingsPatchOperation[] { + const operations: ConfigSettingsPatchOperation[] = []; + + for (const scope of SUBTITLE_CSS_SCOPES) { + const cssPath = getSubtitleCssPath(scope); + const values: Record = { + [cssPath]: getValueAtPath(rawConfig, cssPath), + }; + const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter( + (legacyPath) => + !STARTUP_MIGRATION_EXCLUDED_PATHS.has(legacyPath) && hasPath(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, + }; +} diff --git a/src/main.ts b/src/main.ts index ababe6b6..931ecb99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -375,7 +375,6 @@ import { detectInstalledMpvPlugin, removeLegacyMpvPluginCandidates, resolvePackagedRuntimePluginPath, - syncInstalledFirstRunPluginBinaryPath, } from './main/runtime/first-run-setup-plugin'; import { applyWindowsMpvShortcuts, @@ -494,6 +493,7 @@ import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; 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 { createElectronAppUpdater, @@ -664,7 +664,7 @@ const texthookerService = new Texthooker(() => { yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownAndNPlusOneEnabled = getRuntimeBooleanOption( 'subtitle.annotation.nPlusOne', - config.ankiConnect.knownWords.highlightEnabled, + config.ankiConnect.nPlusOne.enabled, ); return { @@ -1216,6 +1216,17 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ 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), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), @@ -1242,12 +1253,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({ resourcesPath: process.resourcesPath, appExePath: process.execPath, }); -syncInstalledFirstRunPluginBinaryPath({ - platform: process.platform, - homeDir: os.homedir(), - xdgConfigHome: process.env.XDG_CONFIG_HOME, - binaryPath: process.execPath, -}); const firstRunSetupService = createFirstRunSetupService({ platform: process.platform, configDir: CONFIG_DIR, @@ -1807,6 +1812,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({ getConfig: () => configService.getConfig(), getWarnings: () => configService.getWarnings(), reloadConfigStrict: () => configService.reloadConfigStrict(), + onHotReloadApplied: applyConfigHotReloadDiff, defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, createAnkiClient: (url) => new AnkiConnectClient(url), getSettingsWindow: () => appState.configSettingsWindow, @@ -2618,6 +2624,17 @@ const { getLaunchMode: () => getResolvedConfig().mpv.launchMode, platform: process.platform, 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, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, removeSocketPath: (socketPath) => { @@ -4017,6 +4034,17 @@ const { 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), recordAnilistMediaDuration: (durationSec) => { recordAnilistMediaDuration(durationSec); @@ -5189,7 +5217,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: async () => - withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), + resolveCurrentSubtitleForRenderer({ + currentSubText: appState.currentSubText, + currentSubtitleData: appState.currentSubtitleData, + withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload), + }), getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleAss: () => appState.currentSubAssText, getSubtitleSidebarSnapshot: async () => { @@ -5526,7 +5558,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers< enforceUnsupportedWaylandMode(args); }, shouldStartApp: (args: CliArgs) => shouldStartApp(args), - getDefaultSocketPath: () => getDefaultSocketPath(), + getDefaultSocketPath: () => getResolvedConfig().mpv.socketPath || getDefaultSocketPath(), defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT, configDir: CONFIG_DIR, defaultConfig: DEFAULT_CONFIG, @@ -5592,7 +5624,12 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), - onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + onWindowContentReady: () => { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + if (appState.currentSubText.trim()) { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + } + }, onWindowClosed: (windowKind) => { if (windowKind === 'visible') { cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(); diff --git a/src/main/runtime/config-settings-ipc.test.ts b/src/main/runtime/config-settings-ipc.test.ts index 891383f9..2dd9f2e5 100644 --- a/src/main/runtime/config-settings-ipc.test.ts +++ b/src/main/runtime/config-settings-ipc.test.ts @@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [ label: 'Launch mode', description: 'Launch mode setting.', configPath: 'mpv.launchMode', - category: 'playback-sources', - section: 'mpv launcher', + category: 'behavior', + section: 'MPV Launcher', control: 'select', defaultValue: 'windowed', restartBehavior: 'restart', diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index 111bc3c6..218839b0 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -10,7 +10,10 @@ import type { } from '../../types/settings'; import type { ReloadConfigStrictResult } from '../../config'; import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload'; -import { createSaveConfigSettingsPatchHandler } from './config-settings-save'; +import { + createSaveConfigSettingsPatchHandler, + type ConfigSettingsHotReloadDiff, +} from './config-settings-save'; import { createOpenConfigSettingsWindowHandler, type ConfigSettingsWindowLike, @@ -46,6 +49,7 @@ export interface ConfigSettingsRuntimeDeps void; getSettingsWindow(): TWindow | null; setSettingsWindow(window: TWindow | null): void; createSettingsWindow(): TWindow; @@ -122,6 +126,7 @@ export function createConfigSettingsRuntime deps.reloadConfigStrict(), classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next), getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields), + onHotReloadApplied: deps.onHotReloadApplied, }); function ensureConfigFileExists(): string { @@ -199,20 +204,24 @@ export function createConfigSettingsRuntime - typeof deckName === 'string' - ? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(deckName)) - : invalidAnkiListResult('Deck name is required.'), + (_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.getConfigSettingsAnkiModelNames, (_event, draftUrl) => getAnkiList(draftUrl, (client) => client.modelNames()), ); deps.ipcMain.handle( deps.ipcChannels.getConfigSettingsAnkiModelFieldNames, - (_event, modelName, draftUrl) => - typeof modelName === 'string' - ? getAnkiList(draftUrl, (client) => client.modelFieldNames(modelName)) - : invalidAnkiListResult('Note type is required.'), + (_event, modelName, draftUrl) => { + const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : ''; + return normalizedModelName + ? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName)) + : invalidAnkiListResult('Note type is required.'); + }, ); } diff --git a/src/main/runtime/config-settings-save.test.ts b/src/main/runtime/config-settings-save.test.ts index 6b7d8418..45d0336c 100644 --- a/src/main/runtime/config-settings-save.test.ts +++ b/src/main/runtime/config-settings-save.test.ts @@ -66,6 +66,76 @@ test('config settings save returns hot-reloadable diff for watcher path', () => 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', () => { const calls: string[] = []; const previous = DEFAULT_CONFIG; diff --git a/src/main/runtime/config-settings-save.ts b/src/main/runtime/config-settings-save.ts index 1a9ac004..20d254dd 100644 --- a/src/main/runtime/config-settings-save.ts +++ b/src/main/runtime/config-settings-save.ts @@ -24,6 +24,7 @@ export interface ConfigSettingsSaveDeps { reloadConfigStrict(): ReloadConfigStrictResult; classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff; getRestartRequiredSections(restartRequiredFields: string[]): string[]; + onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void; } export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) { @@ -86,6 +87,9 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep } const diff = deps.classifyDiff(previousConfig, reloadResult.config); + if (diff.hotReloadFields.length > 0) { + deps.onHotReloadApplied?.(diff, reloadResult.config); + } return { ok: true, diff --git a/src/main/runtime/current-subtitle-snapshot.test.ts b/src/main/runtime/current-subtitle-snapshot.test.ts new file mode 100644 index 00000000..aa449b8f --- /dev/null +++ b/src/main/runtime/current-subtitle-snapshot.test.ts @@ -0,0 +1,47 @@ +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); +}); diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts new file mode 100644 index 00000000..c24b8150 --- /dev/null +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -0,0 +1,23 @@ +import type { SubtitleData } from '../../types'; + +export async function resolveCurrentSubtitleForRenderer(deps: { + currentSubText: string; + currentSubtitleData: SubtitleData | null; + withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; +}): Promise { + if (deps.currentSubtitleData?.text === deps.currentSubText) { + return deps.withCurrentSubtitleTiming(deps.currentSubtitleData); + } + + if (!deps.currentSubText.trim()) { + return deps.withCurrentSubtitleTiming({ + text: deps.currentSubText, + tokens: null, + }); + } + + return deps.withCurrentSubtitleTiming({ + text: deps.currentSubText, + tokens: null, + }); +} diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts index a29493a0..602701e9 100644 --- a/src/main/runtime/first-run-setup-plugin.test.ts +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -10,7 +10,6 @@ import { removeLegacyMpvPluginCandidates, resolvePackagedFirstRunPluginAssets, resolvePackagedRuntimePluginPath, - syncInstalledFirstRunPluginBinaryPath, } from './first-run-setup-plugin'; import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state'; @@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () = }); }); -test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome); - - fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true }); - fs.writeFileSync( - installPaths.pluginConfigPath, - 'binary_path=\nsocket_path=/tmp/subminer-socket\n', - ); - - const result = syncInstalledFirstRunPluginBinaryPath({ - platform: 'linux', - homeDir, - xdgConfigHome, - binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', - }); - - assert.deepEqual(result, { - updated: true, - configPath: installPaths.pluginConfigPath, - }); - assert.equal( - fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), - 'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n', - ); - }); -}); - -test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome); - - fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true }); - fs.writeFileSync( - installPaths.pluginConfigPath, - 'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n', - ); - - const result = syncInstalledFirstRunPluginBinaryPath({ - platform: 'linux', - homeDir, - xdgConfigHome, - binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', - }); - - assert.deepEqual(result, { - updated: false, - configPath: installPaths.pluginConfigPath, - }); - assert.equal( - fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), - 'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n', - ); - }); -}); - test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/src/main/runtime/first-run-setup-plugin.ts b/src/main/runtime/first-run-setup-plugin.ts index f4a7337b..e2644c5c 100644 --- a/src/main/runtime/first-run-setup-plugin.ts +++ b/src/main/runtime/first-run-setup-plugin.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state'; +import type { MpvInstallPaths } from '../../shared/setup-state'; export interface InstalledFirstRunPluginCandidate { path: string; @@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult { failedPaths: Array<{ path: string; message: string }>; } -function rewriteInstalledWindowsPluginConfig(configPath: string): void { - const content = fs.readFileSync(configPath, 'utf8'); - const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket'); - if (updated !== content) { - fs.writeFileSync(configPath, updated, 'utf8'); - } -} - -function sanitizePluginConfigValue(value: string): string { - return value.replace(/[\r\n]/g, '').trim(); -} - -function upsertPluginConfigLine(content: string, key: string, value: string): string { - const normalizedValue = sanitizePluginConfigValue(value); - const line = `${key}=${normalizedValue}`; - const pattern = new RegExp(`^${key}=.*$`, 'm'); - if (pattern.test(content)) { - return content.replace(pattern, line); - } - - const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n'; - return `${content}${suffix}${line}\n`; -} - -function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean { - const content = fs.readFileSync(configPath, 'utf8'); - const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath); - if (updated === content) { - return false; - } - fs.writeFileSync(configPath, updated, 'utf8'); - return true; -} - -function readInstalledPluginBinaryPath(configPath: string): string | null { - const content = fs.readFileSync(configPath, 'utf8'); - const match = content.match(/^binary_path=(.*)$/m); - if (!match) { - return null; - } - const rawValue = match[1] ?? ''; - const value = sanitizePluginConfigValue(rawValue); - return value.length > 0 ? value : null; -} - export function resolvePackagedFirstRunPluginAssets(deps: { dirname: string; appPath: string; @@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: { failedPaths, }; } - -export function syncInstalledFirstRunPluginBinaryPath(options: { - platform: NodeJS.Platform; - homeDir: string; - xdgConfigHome?: string; - binaryPath: string; -}): { updated: boolean; configPath: string | null } { - const installPaths = resolveDefaultMpvInstallPaths( - options.platform, - options.homeDir, - options.xdgConfigHome, - ); - if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) { - return { updated: false, configPath: null }; - } - - const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath); - if (configuredBinaryPath) { - return { updated: false, configPath: installPaths.pluginConfigPath }; - } - - const updated = rewriteInstalledPluginBinaryPath( - installPaths.pluginConfigPath, - options.binaryPath, - ); - if (options.platform === 'win32') { - rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath); - } - return { - updated, - configPath: installPaths.pluginConfigPath, - }; -} diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index b8257c64..2c2e2eb5 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -20,6 +20,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( getLaunchMode: () => deps.getLaunchMode(), platform: deps.platform, execPath: deps.execPath, + getPluginRuntimeConfig: deps.getPluginRuntimeConfig, defaultMpvLogPath: deps.defaultMpvLogPath, defaultMpvArgs: deps.defaultMpvArgs, removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath), diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index 7b97477a..d312478d 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -56,6 +56,51 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); }); +test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => { + const spawnedArgs: string[][] = []; + const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ + getSocketPath: () => '/tmp/subminer.sock', + getLaunchMode: () => 'normal', + platform: 'linux', + execPath: '/opt/SubMiner/SubMiner.AppImage', + getPluginRuntimeConfig: () => ({ + socketPath: '/tmp/ignored-config.sock', + binaryPath: '/custom/SubMiner.AppImage', + backend: 'x11', + autoStart: true, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'F8', + }), + defaultMpvLogPath: '/tmp/mp.log', + defaultMpvArgs: ['--sid=auto'], + removeSocketPath: () => {}, + spawnMpv: (args) => { + spawnedArgs.push(args); + return { + on: () => {}, + unref: () => {}, + }; + }, + logWarn: () => {}, + logInfo: () => {}, + }); + + launch(); + const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts=')); + assert.match(scriptOpts ?? '', /subminer-binary_path=\/custom\/SubMiner\.AppImage/); + assert.match(scriptOpts ?? '', /subminer-socket_path=\/tmp\/subminer\.sock/); + assert.match(scriptOpts ?? '', /subminer-backend=x11/); + assert.match(scriptOpts ?? '', /subminer-auto_start=yes/); + assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/); + assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/); + assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/); + assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/); + assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); +}); + test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { let autoLaunchInFlight: Promise | null = null; let launchCalls = 0; diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index eb41370a..e72ca17e 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -1,4 +1,8 @@ import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; +import { + buildSubminerPluginRuntimeScriptOptParts, + type SubminerPluginRuntimeScriptOptConfig, +} from '../../shared/subminer-plugin-script-opts'; import type { MpvLaunchMode } from '../../types/config'; type MpvClientLike = { @@ -40,6 +44,7 @@ export type LaunchMpvForJellyfinDeps = { getLaunchMode: () => MpvLaunchMode; platform: NodeJS.Platform; execPath: string; + getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; defaultMpvLogPath: string; defaultMpvArgs: readonly string[]; removeSocketPath: (socketPath: string) => void; @@ -59,7 +64,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor } } - const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`; + const pluginRuntimeConfig = deps.getPluginRuntimeConfig?.(); + const scriptOptParts = pluginRuntimeConfig + ? buildSubminerPluginRuntimeScriptOptParts( + { + ...pluginRuntimeConfig, + socketPath, + }, + deps.execPath, + ) + : [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`]; + const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`; const mpvArgs = [ ...deps.defaultMpvArgs, ...buildMpvLaunchModeArgs(deps.getLaunchMode()), diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 035a8664..52fcd4c7 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -54,6 +54,34 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', () assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']); }); +test('mpv connection handler runs connected hook on connect', () => { + const calls: string[] = []; + const handler = createHandleMpvConnectionChangeHandler({ + reportJellyfinRemoteStopped: () => calls.push('report-stop'), + refreshDiscordPresence: () => calls.push('presence-refresh'), + syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), + onConnected: () => calls.push('connected-hook'), + hasInitialPlaybackQuitOnDisconnectArg: () => false, + isOverlayRuntimeInitialized: () => false, + shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false, + isQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => calls.push('schedule'), + isMpvConnected: () => false, + quitApp: () => calls.push('quit'), + }); + + handler({ connected: true }); + handler({ connected: false }); + + assert.deepEqual(calls, [ + 'presence-refresh', + 'sync-overlay-mpv-sub', + 'connected-hook', + 'presence-refresh', + 'report-stop', + ]); +}); + test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => { const calls: string[] = []; const handler = createHandleMpvConnectionChangeHandler({ diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 22d7666d..644185b5 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -27,6 +27,7 @@ export function createHandleMpvConnectionChangeHandler(deps: { reportJellyfinRemoteStopped: () => void; refreshDiscordPresence: () => void; syncOverlayMpvSubtitleSuppression: () => void; + onConnected?: () => void; hasInitialPlaybackQuitOnDisconnectArg: () => boolean; isOverlayRuntimeInitialized: () => boolean; shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean; @@ -39,6 +40,7 @@ export function createHandleMpvConnectionChangeHandler(deps: { deps.refreshDiscordPresence(); if (connected) { deps.syncOverlayMpvSubtitleSuppression(); + deps.onConnected?.(); return; } deps.reportJellyfinRemoteStopped(); diff --git a/src/main/runtime/mpv-jellyfin-defaults.ts b/src/main/runtime/mpv-jellyfin-defaults.ts index 6ee1247b..f5bf038c 100644 --- a/src/main/runtime/mpv-jellyfin-defaults.ts +++ b/src/main/runtime/mpv-jellyfin-defaults.ts @@ -1,4 +1,5 @@ import type { MpvRuntimeClientLike } from '../../core/services/mpv'; +import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path'; export function createApplyJellyfinMpvDefaultsHandler(deps: { sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void; @@ -17,9 +18,6 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: { export function createGetDefaultSocketPathHandler(deps: { platform: string }) { return (): string => { - if (deps.platform === 'win32') { - return '\\\\.\\pipe\\subminer-socket'; - } - return '/tmp/subminer-socket'; + return getDefaultMpvSocketPath(deps.platform as NodeJS.Platform); }; } diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index ca44f9b6..b35c3d02 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -95,3 +95,66 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.ok(calls.includes('sync-immersion')); assert.ok(calls.includes('flush-playback')); }); + +test('main mpv event binder runs mpv-connected callback on connection', () => { + const handlers = new Map void>(); + const calls: string[] = []; + + const bind = createBindMpvMainEventHandlersHandler({ + reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), + syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), + onMpvConnected: () => calls.push('mpv-connected'), + resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), + hasInitialPlaybackQuitOnDisconnectArg: () => false, + isOverlayRuntimeInitialized: () => false, + shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false, + isQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + isMpvConnected: () => true, + quitApp: () => {}, + + recordImmersionSubtitleLine: () => {}, + hasSubtitleTimingTracker: () => false, + recordSubtitleTiming: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + setCurrentSubText: () => {}, + broadcastSubtitle: () => {}, + onSubtitleChange: () => {}, + refreshDiscordPresence: () => calls.push('presence-refresh'), + + setCurrentSubAssText: () => {}, + broadcastSubtitleAss: () => {}, + broadcastSecondarySubtitle: () => {}, + + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + notifyImmersionTitleUpdate: () => {}, + + recordPlaybackPosition: () => {}, + recordMediaDuration: () => {}, + reportJellyfinRemoteProgress: () => {}, + recordPauseState: () => {}, + + updateSubtitleRenderMetrics: () => {}, + setPreviousSecondarySubVisibility: () => {}, + }); + + bind({ + on: (event, handler) => { + handlers.set(event, handler as (payload: unknown) => void); + }, + }); + + handlers.get('connection-change')?.({ connected: true }); + + assert.ok(calls.includes('mpv-connected')); +}); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index b7518b61..b1bf530d 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -25,6 +25,7 @@ type AnilistPostWatchRunOptions = { export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteStopped: () => void; syncOverlayMpvSubtitleSuppression: () => void; + onMpvConnected?: () => void; resetSubtitleSidebarEmbeddedLayout: () => void; scheduleCharacterDictionarySync?: () => void; hasInitialPlaybackQuitOnDisconnectArg: () => boolean; @@ -83,6 +84,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), refreshDiscordPresence: () => deps.refreshDiscordPresence(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), + onConnected: () => deps.onMpvConnected?.(), hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 9874bf11..c18c7a59 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -46,6 +46,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { quitApp: () => void; reportJellyfinRemoteStopped: () => void; syncOverlayMpvSubtitleSuppression: () => void; + onMpvConnected?: () => void; maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise; recordAnilistMediaDuration?: (durationSec: number) => void; logSubtitleTimingError: (message: string, error: unknown) => void; @@ -93,6 +94,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { return () => ({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), + onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined, hasInitialPlaybackQuitOnDisconnectArg, isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg, diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 08ce6a6f..49c70470 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -191,6 +191,38 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi ); }); +test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => { + const args = buildWindowsMpvLaunchArgs( + ['C:\\video.mkv'], + ['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + 'normal', + { + socketPath: '\\\\.\\pipe\\ignored-config-socket', + binaryPath: 'C:\\Custom\\SubMiner.exe', + backend: 'windows', + autoStart: true, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: false, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'F8', + }, + ); + + const scriptOpts = args.find((arg) => arg.startsWith('--script-opts=')); + assert.match(scriptOpts ?? '', /subminer-binary_path=C:\\Custom\\SubMiner\.exe/); + assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\custom-subminer-socket/); + assert.match(scriptOpts ?? '', /subminer-backend=windows/); + assert.match(scriptOpts ?? '', /subminer-auto_start=yes/); + assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/); + assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/); + assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/); + assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/); + assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); +}); + test('launchWindowsMpv reports missing mpv path', async () => { const errors: string[] = []; const result = await launchWindowsMpv( diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 0be38759..42898f31 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; +import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts'; import type { MpvLaunchMode } from '../../types/config'; +import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts'; import type { InstalledMpvPluginDetection } from './first-run-setup-plugin'; export interface WindowsMpvLaunchDeps { @@ -102,6 +104,7 @@ export function buildWindowsMpvLaunchArgs( binaryPath?: string, pluginEntrypointPath?: string, launchMode: MpvLaunchMode = 'normal', + pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig, ): string[] { const launchIdle = targets.length === 0; const inputIpcServer = @@ -112,10 +115,18 @@ export function buildWindowsMpvLaunchArgs( : null; const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0; const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath; - const scriptOptPairs = shouldPassSubminerScriptOpts - ? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`] - : []; - if (hasBinaryPath) { + const scriptOptPairs = pluginRuntimeConfig + ? buildSubminerPluginRuntimeScriptOptParts( + { + ...pluginRuntimeConfig, + socketPath: inputIpcServer, + }, + binaryPath ?? '', + ) + : shouldPassSubminerScriptOpts + ? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`] + : []; + if (!pluginRuntimeConfig && hasBinaryPath) { scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`); } const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null; @@ -149,6 +160,7 @@ export async function launchWindowsMpv( configuredMpvPath?: string, launchMode: MpvLaunchMode = 'normal', runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy, + pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig, ): Promise<{ ok: boolean; mpvPath: string }> { const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath); const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath); @@ -192,6 +204,7 @@ export async function launchWindowsMpv( binaryPath, runtimePluginEntrypointPath, launchMode, + pluginRuntimeConfig, ), ); return { ok: true, mpvPath }; diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts index 4ddb2771..e1d3bd06 100644 --- a/src/preload-settings.test.ts +++ b/src/preload-settings.test.ts @@ -21,3 +21,13 @@ test('settings preload exposes Anki lookup helpers', () => { assert.match(source, new RegExp(`${method}:`)); } }); + +test('overlay preload queues subtitle updates until renderer listener registration', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); + + assert.match( + source, + /const onSubtitleSetEvent =\s*createQueuedIpcListenerWithPayload\(\s*IPC_CHANNELS\.event\.subtitleSet,/, + ); + assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/); +}); diff --git a/src/preload.ts b/src/preload.ts index 8972dda9..0d48f451 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -161,29 +161,39 @@ const onKikuFieldGroupingRequestEvent = IPC_CHANNELS.event.kikuFieldGroupingRequest, (payload) => payload as KikuFieldGroupingRequestData, ); +const onSubtitleSetEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.subtitleSet, + (payload) => payload as SubtitleData, +); +const onSubtitleVisibilityEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.subtitleVisibility, + (payload) => payload === true, +); +const onSubtitlePositionSetEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.subtitlePositionSet, + (payload) => payload as SubtitlePosition | null, +); +const onSecondarySubtitleSetEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.secondarySubtitleSet, + (payload) => (typeof payload === 'string' ? payload : ''), +); +const onSecondarySubtitleModeEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.secondarySubtitleMode, + (payload) => payload as SecondarySubMode, +); const electronAPI: ElectronAPI = { getOverlayLayer: () => overlayLayer, onSubtitle: (callback: (data: SubtitleData) => void) => { - ipcRenderer.on(IPC_CHANNELS.event.subtitleSet, (_event: IpcRendererEvent, data: SubtitleData) => - callback(data), - ); + onSubtitleSetEvent(callback); }, onVisibility: (callback: (visible: boolean) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.subtitleVisibility, - (_event: IpcRendererEvent, visible: boolean) => callback(visible), - ); + onSubtitleVisibilityEvent(callback); }, onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.subtitlePositionSet, - (_event: IpcRendererEvent, position: SubtitlePosition | null) => { - callback(position); - }, - ); + onSubtitlePositionSetEvent(callback); }, getOverlayVisibility: (): Promise => @@ -290,17 +300,11 @@ const electronAPI: ElectronAPI = { }, onSecondarySub: (callback: (text: string) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.secondarySubtitleSet, - (_event: IpcRendererEvent, text: string) => callback(text), - ); + onSecondarySubtitleSetEvent(callback); }, onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.secondarySubtitleMode, - (_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode), - ); + onSecondarySubtitleModeEvent(callback); }, getSecondarySubMode: (): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index a25a30b9..cb704220 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; @@ -108,6 +110,7 @@ function installKeyboardTestGlobals() { const mpvCommands: Array> = []; const sessionActions: Array<{ actionId: string; payload?: unknown }> = []; let sessionBindings: CompiledSessionBinding[] = []; + let getSessionBindingsImpl: () => Promise = async () => sessionBindings; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; let markWatchedKey = 'KeyW'; @@ -216,7 +219,7 @@ function installKeyboardTestGlobals() { }, electronAPI: { getKeybindings: async () => [], - getSessionBindings: async () => sessionBindings, + getSessionBindings: () => getSessionBindingsImpl(), getConfiguredShortcuts: async () => configuredShortcuts, sendMpvCommand: (command: Array) => { mpvCommands.push(command); @@ -366,6 +369,9 @@ function installKeyboardTestGlobals() { setSessionBindings: (value: CompiledSessionBinding[]) => { sessionBindings = value; }, + setGetSessionBindings: (value: () => Promise) => { + getSessionBindingsImpl = value; + }, setMarkActiveVideoWatchedResult: (value: boolean) => { markActiveVideoWatchedResult = value; }, @@ -462,6 +468,16 @@ function createKeyboardHandlerHarness() { }; } +test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'), 'utf8'); + const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();'); + const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();'); + + assert.notEqual(keyboardSetupIndex, -1); + assert.notEqual(subtitleRequestIndex, -1); + assert.equal(keyboardSetupIndex < subtitleRequestIndex, true); +}); + test('primary subtitle visibility key cycles modes with primary OSD without mpv sub-visibility', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -498,6 +514,25 @@ test('primary subtitle visibility key cycles modes with primary OSD without mpv } }); +test('mpv input forwarding installs local key handling when session binding IPC stalls', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + testGlobals.setGetSessionBindings(() => new Promise(() => {})); + const setupResult = await Promise.race([ + handlers.setupMpvInputForwarding().then(() => 'resolved'), + wait(25).then(() => 'pending'), + ]); + + assert.equal(setupResult, 'resolved'); + testGlobals.dispatchKeydown({ key: '`', code: 'Backquote' }); + + assert.equal(testGlobals.statsToggleOverlayCalls(), 1); + } finally { + testGlobals.restore(); + } +}); + test('session help chord resolver follows remapped session bindings', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 3f67a0f1..72cff41b 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -44,6 +44,7 @@ export function createKeyboardHandlers( actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple'; timeout: ReturnType | null; } | null = null; + let mpvInputForwardingListenersInstalled = false; const CHORD_MAP = new Map< string, @@ -940,7 +941,7 @@ export function createKeyboardHandlers( } } - async function setupMpvInputForwarding(): Promise { + async function loadMpvInputForwardingConfig(): Promise { const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ window.electronAPI.getSessionBindings(), window.electronAPI.getConfiguredShortcuts(), @@ -950,6 +951,42 @@ export function createKeyboardHandlers( updateSessionBindings(sessionBindings); updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); syncKeyboardTokenSelection(); + } + + async function setupMpvInputForwarding(): Promise { + installMpvInputForwardingListeners(); + syncKeyboardTokenSelection(); + + let configLoadSettled = false; + let configLoadError: unknown = null; + const configLoad = loadMpvInputForwardingConfig().then( + () => { + configLoadSettled = true; + }, + (error) => { + configLoadSettled = true; + configLoadError = error; + console.error('Failed to load overlay keyboard configuration.', error); + }, + ); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + if (!configLoadSettled) { + void configLoad; + return; + } + if (configLoadError) { + return; + } + } + + function installMpvInputForwardingListeners(): void { + if (mpvInputForwardingListenersInstalled) { + return; + } + mpvInputForwardingListenersInstalled = true; const subtitleMutationObserver = new MutationObserver(() => { syncKeyboardTokenSelection(); diff --git a/src/renderer/modals/session-help.test.ts b/src/renderer/modals/session-help.test.ts index 0524e28d..c06f1289 100644 --- a/src/renderer/modals/session-help.test.ts +++ b/src/renderer/modals/session-help.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { SPECIAL_COMMANDS } from '../../config/definitions'; +import { SPECIAL_COMMANDS } from '../../config/definitions/shared'; import { createRendererState } from '../state.js'; import { createSessionHelpModal, @@ -30,6 +32,16 @@ test('session help formats bracket keybindings as physical keys', () => { assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + ['); }); +test('session help imports browser-safe special command constants', () => { + const source = fs.readFileSync( + path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help.ts'), + 'utf8', + ); + + assert.match(source, /from ['"]\.\.\/\.\.\/config\/definitions\/shared['"]/); + assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/); +}); + function createClassList(initialTokens: string[] = []) { const tokens = new Set(initialTokens); return { diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index 6d34f7b7..41935f88 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -1,6 +1,6 @@ import type { Keybinding } from '../../types'; import type { ShortcutsConfig } from '../../types'; -import { SPECIAL_COMMANDS } from '../../config/definitions'; +import { SPECIAL_COMMANDS } from '../../config/definitions/shared'; import type { ModalStateReader, RendererContext } from '../context'; type SessionHelpBindingInfo = { diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 4092fca2..ebb894e8 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -141,6 +141,11 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback activeLineColor: '#f5bde6', activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)', hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)', + css: { + 'font-size': '22px', + color: '#ffffff', + '--subtitle-sidebar-timestamp-color': '#aaaaaa', + }, }, }; @@ -175,6 +180,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback const overlayClassList = createClassList(); const modalClassList = createClassList(['hidden']); const cueList = createListStub(); + const contentStyleValues = new Map(); + const contentStyle = { + setProperty: (name: string, value: string) => { + contentStyleValues.set(name, value); + }, + } as CSSStyleDeclaration & { color?: string }; const ctx = { dom: { overlay: { classList: overlayClassList }, @@ -187,6 +198,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback subtitleSidebarContent: { classList: createClassList(), getBoundingClientRect: () => ({ width: 420 }), + style: contentStyle, }, subtitleSidebarClose: { addEventListener: () => {} }, subtitleSidebarStatus: { textContent: '' }, @@ -207,6 +219,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback assert.equal(cueList.children.length, 2); assert.equal(cueList.scrollTop, 0); assert.deepEqual(cueList.scrollToCalls, []); + assert.equal(contentStyleValues.get('font-size'), '22px'); + assert.equal(contentStyle.color, '#ffffff'); + assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa'); modal.seekToCue(snapshot.cues[0]!); assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index ceefff1d..abccc216 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -55,6 +55,24 @@ function formatCueTimestamp(seconds: number): string { return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } +function applySidebarCssDeclarations( + target: HTMLElement, + declarations: Record, +): void { + const targetStyle = (target as HTMLElement & { style?: CSSStyleDeclaration }).style; + if (!targetStyle) return; + for (const [property, rawValue] of Object.entries(declarations)) { + const value = rawValue.trim(); + if (value.length === 0) continue; + if (property.includes('-')) { + targetStyle.setProperty(property, value); + continue; + } + const styleTarget = targetStyle as unknown as Record; + styleTarget[property] = value; + } +} + export function findActiveSubtitleCueIndex( cues: SubtitleCue[], current: { text: string; startTime?: number | null } | null, @@ -266,6 +284,7 @@ export function createSubtitleSidebarModal( '--subtitle-sidebar-hover-background-color', snapshot.config.hoverLineBackgroundColor, ); + applySidebarCssDeclarations(ctx.dom.subtitleSidebarContent, snapshot.config.css ?? {}); } function seekToCue(cue: SubtitleCue): void { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 59ffb9c6..380e3dea 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -613,6 +613,8 @@ async function init(): Promise { }); }); + await keyboardHandlers.setupMpvInputForwarding(); + let initialSubtitle: SubtitleData | string = ''; try { initialSubtitle = await window.electronAPI.getCurrentSubtitle(); @@ -698,8 +700,6 @@ async function init(): Promise { }); }); mouseHandlers.setupDragging(); - - await keyboardHandlers.setupMpvInputForwarding(); try { ctx.state.controllerConfig = await window.electronAPI.getControllerConfig(); } catch (error) { diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 1b74000a..4fc2e2fa 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -14,7 +14,7 @@ import type { CharacterDictionarySelectionSnapshot, PrimarySubMode, SubtitlePosition, - SubtitleSidebarConfig, + SubtitleSidebarSnapshotConfig, SubtitleCue, SubsyncSourceTrack, YoutubePickerOpenPayload, @@ -98,7 +98,7 @@ export type RendererState = { subtitleSidebarToggleKey: string; subtitleSidebarPauseVideoOnHover: boolean; subtitleSidebarAutoScroll: boolean; - subtitleSidebarConfig: Required | null; + subtitleSidebarConfig: SubtitleSidebarSnapshotConfig | null; subtitleSidebarManualScrollUntilMs: number; subtitleSidebarPausedByHover: boolean; diff --git a/src/renderer/style.css b/src/renderer/style.css index bb376e56..1e4782e8 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1912,10 +1912,10 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal { margin-left: auto; font-family: var( --subtitle-sidebar-font-family, - 'M PLUS 1', - 'Noto Sans CJK JP', - 'Hiragino Sans', - sans-serif + Hiragino Sans, + M PLUS 1, + Source Han Sans JP, + Noto Sans CJK JP ); font-size: var(--subtitle-sidebar-font-size, 16px); background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9)); @@ -2062,7 +2062,7 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent { } .subtitle-sidebar-timestamp { - font-size: calc(var(--subtitle-sidebar-font-size, 16px) * 0.72); + font-size: 0.72em; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 0.03em; diff --git a/src/settings/key-input.test.ts b/src/settings/key-input.test.ts index a74b43dd..cb505da0 100644 --- a/src/settings/key-input.test.ts +++ b/src/settings/key-input.test.ts @@ -58,6 +58,23 @@ test('keyboardEventToConfigKey formats bare key-code fields without modifiers', ); }); +test('keyboardEventToConfigKey formats mpv key bindings from learned input', () => { + assert.equal( + keyboardEventToConfigKey( + { code: 'Tab', key: 'Tab', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false }, + 'mpv-key', + ), + 'TAB', + ); + assert.equal( + keyboardEventToConfigKey( + { code: 'KeyK', key: 'K', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false }, + 'mpv-key', + ), + 'Ctrl+Shift+K', + ); +}); + test('MPV keybinding rows save default key moves as a disable plus replacement', () => { const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }]; const rows = createMpvKeybindingRows(defaults, []); diff --git a/src/settings/key-input.ts b/src/settings/key-input.ts index 1fb2f330..a3556e8f 100644 --- a/src/settings/key-input.ts +++ b/src/settings/key-input.ts @@ -1,6 +1,6 @@ import type { Keybinding } from '../types/runtime'; -export type KeyInputMode = 'accelerator' | 'dom-code' | 'code'; +export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key'; export interface KeyboardInputLike { code: string; @@ -54,6 +54,31 @@ const ELECTRON_KEY_BY_CODE: Record = { Tab: 'Tab', }; +const MPV_KEY_BY_CODE: Record = { + Backspace: 'BS', + Backquote: '`', + Backslash: '\\', + BracketLeft: '[', + BracketRight: ']', + Comma: ',', + Delete: 'DEL', + End: 'END', + Enter: 'ENTER', + Equal: '=', + Escape: 'ESC', + Home: 'HOME', + Insert: 'INS', + Minus: '-', + PageDown: 'PGDWN', + PageUp: 'PGUP', + Period: '.', + Quote: "'", + Semicolon: ';', + Slash: '/', + Space: 'SPACE', + Tab: 'TAB', +}; + function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean { return JSON.stringify(a) === JSON.stringify(b); } @@ -79,6 +104,17 @@ function electronKeyToken(input: KeyboardInputLike): string | null { return ELECTRON_KEY_BY_CODE[input.code] ?? null; } +function mpvKeyToken(input: KeyboardInputLike): string | null { + if (/^Key[A-Z]$/.test(input.code)) { + return input.key.length === 1 ? input.key : input.code.slice(3).toLowerCase(); + } + if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5); + if (/^Numpad[0-9]$/.test(input.code)) return `KP${input.code.slice(6)}`; + if (/^F\d{1,2}$/.test(input.code)) return input.code; + if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '').toUpperCase(); + return MPV_KEY_BY_CODE[input.code] ?? null; +} + export function keyboardEventToConfigKey( input: KeyboardInputLike, mode: KeyInputMode, @@ -92,6 +128,15 @@ export function keyboardEventToConfigKey( } const parts: string[] = []; + if (mode === 'mpv-key') { + if (input.ctrlKey) parts.push('Ctrl'); + if (input.altKey) parts.push('Alt'); + if (input.shiftKey) parts.push('Shift'); + if (input.metaKey) parts.push('Meta'); + const key = mpvKeyToken(input); + return key ? [...parts, key].join('+') : null; + } + if (mode === 'accelerator') { if (input.ctrlKey || input.metaKey) parts.push('CommandOrControl'); if (input.altKey) parts.push('Alt'); diff --git a/src/settings/settings-anki-controls.test.ts b/src/settings/settings-anki-controls.test.ts index 3f569eca..94ec2143 100644 --- a/src/settings/settings-anki-controls.test.ts +++ b/src/settings/settings-anki-controls.test.ts @@ -2,24 +2,42 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as ankiControls from './settings-anki-controls'; -test('note field model preference chooses Kiku before configured Lapis default', () => { +test('note field model preference prefers exact Kiku over configured model', () => { assert.equal( ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'), 'Kiku', ); }); -test('note field model preference falls back to Lapis when Kiku is unavailable', () => { +test('note field model preference ignores configured model case-insensitively', () => { assert.equal( - ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'), - 'Lapis Morph', + ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'), + 'Kiku', ); }); +test('note field model preference prefers exact Lapis when Kiku is unavailable', () => { + assert.equal( + ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''), + 'Lapis', + ); +}); + +test('note field model preference prefers exact Kiku over exact Lapis', () => { + assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Lapis', 'Kiku'], ''), 'Kiku'); +}); + test('note field model preference does not treat partial Kiku matches as Kiku', () => { assert.equal( ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'), - 'Lapis Morph', + '', + ); +}); + +test('note field model preference does not treat partial Lapis matches as Lapis', () => { + assert.equal( + ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'), + '', ); }); diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts index 8a70c036..195b469e 100644 --- a/src/settings/settings-anki-controls.ts +++ b/src/settings/settings-anki-controls.ts @@ -62,9 +62,9 @@ export function selectPreferredNoteFieldModelName( return exactKiku; } - const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis')); - if (lapis) { - return lapis; + const exactLapis = modelNames.find((name) => name.toLowerCase() === 'lapis'); + if (exactLapis) { + return exactLapis; } return ''; @@ -111,7 +111,20 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void { if (state.ankiConnectUrl === nextUrl) { return; } + const hasAnkiMetadata = + state.deckNames !== null || + state.deckNamesLoading || + state.deckFieldNames.size > 0 || + state.deckFieldNamesLoading.size > 0 || + state.modelNames !== null || + state.modelNamesLoading || + state.modelFieldNames.size > 0 || + state.modelFieldNamesLoading.size > 0; state.ankiConnectUrl = nextUrl; + if (hasAnkiMetadata) { + state.noteFieldModelName = ''; + state.noteFieldModelNameManuallySelected = false; + } state.deckNames = null; state.deckNamesLoading = false; state.deckNamesError = null; @@ -129,9 +142,11 @@ function syncAnkiConnectUrl(draftUrl: string | undefined): void { async function loadAnkiDeckNames(draftUrl?: string): Promise { syncAnkiConnectUrl(draftUrl); if (state.deckNames || state.deckNamesLoading) return; + const requestUrl = state.ankiConnectUrl; state.deckNamesLoading = true; try { const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl); + if (state.ankiConnectUrl !== requestUrl) return; if (result.ok) { state.deckNames = uniqueSorted(result.values); state.deckNamesError = null; @@ -140,11 +155,14 @@ async function loadAnkiDeckNames(draftUrl?: string): Promise { state.deckNamesError = result.error ?? 'Failed to load Anki decks.'; } } catch (error) { + if (state.ankiConnectUrl !== requestUrl) return; state.deckNames = []; state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.'; } finally { - state.deckNamesLoading = false; - requestRender(); + if (state.ankiConnectUrl === requestUrl) { + state.deckNamesLoading = false; + requestRender(); + } } } @@ -157,9 +175,11 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom ) { return; } + const requestUrl = state.ankiConnectUrl; state.deckFieldNamesLoading.add(deckName); try { const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl); + if (state.ankiConnectUrl !== requestUrl) return; if (result.ok) { state.deckFieldNames.set(deckName, uniqueSorted(result.values)); state.deckFieldNamesErrors.delete(deckName); @@ -171,23 +191,28 @@ async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Prom ); } } catch (error) { + if (state.ankiConnectUrl !== requestUrl) return; state.deckFieldNames.set(deckName, []); state.deckFieldNamesErrors.set( deckName, error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`, ); } finally { - state.deckFieldNamesLoading.delete(deckName); - requestRender(); + if (state.ankiConnectUrl === requestUrl) { + state.deckFieldNamesLoading.delete(deckName); + requestRender(); + } } } async function loadAnkiModelNames(draftUrl?: string): Promise { syncAnkiConnectUrl(draftUrl); if (state.modelNames || state.modelNamesLoading) return; + const requestUrl = state.ankiConnectUrl; state.modelNamesLoading = true; try { const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl); + if (state.ankiConnectUrl !== requestUrl) return; if (result.ok) { state.modelNames = uniqueSorted(result.values); state.modelNamesError = null; @@ -202,12 +227,15 @@ async function loadAnkiModelNames(draftUrl?: string): Promise { state.modelNamesError = result.error ?? 'Failed to load Anki note types.'; } } catch (error) { + if (state.ankiConnectUrl !== requestUrl) return; state.modelNames = []; state.modelNamesError = error instanceof Error ? error.message : 'Failed to load Anki note types.'; } finally { - state.modelNamesLoading = false; - requestRender(); + if (state.ankiConnectUrl === requestUrl) { + state.modelNamesLoading = false; + requestRender(); + } } } @@ -220,9 +248,11 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr ) { return; } + const requestUrl = state.ankiConnectUrl; state.modelFieldNamesLoading.add(modelName); try { const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl); + if (state.ankiConnectUrl !== requestUrl) return; if (result.ok) { state.modelFieldNames.set(modelName, uniqueSorted(result.values)); state.modelFieldNamesErrors.delete(modelName); @@ -234,14 +264,17 @@ async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Pr ); } } catch (error) { + if (state.ankiConnectUrl !== requestUrl) return; state.modelFieldNames.set(modelName, []); state.modelFieldNamesErrors.set( modelName, error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`, ); } finally { - state.modelFieldNamesLoading.delete(modelName); - requestRender(); + if (state.ankiConnectUrl === requestUrl) { + state.modelFieldNamesLoading.delete(modelName); + requestRender(); + } } } diff --git a/src/settings/settings-controls.ts b/src/settings/settings-controls.ts index 8da7c7c3..0a886827 100644 --- a/src/settings/settings-controls.ts +++ b/src/settings/settings-controls.ts @@ -153,6 +153,10 @@ export function renderControl( return renderKeyboardInput(context, field, 'code'); } + if (field.control === 'mpv-key') { + return renderKeyboardInput(context, field, 'mpv-key'); + } + if (field.control === 'known-words-decks') { return renderKnownWordsDecksInput(context, field); } diff --git a/src/settings/settings-model.test.ts b/src/settings/settings-model.test.ts index b1df8af0..3570c07c 100644 --- a/src/settings/settings-model.test.ts +++ b/src/settings/settings-model.test.ts @@ -66,6 +66,27 @@ test('filterSettingsFields searches label, section, and config path', () => { assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []); }); +test('filterSettingsFields normalizes punctuation in query terms', () => { + const nPlusOneFields: ConfigSettingsField[] = [ + { + id: 'ankiConnect.nPlusOne.enabled', + label: 'Enable N+1', + description: 'Highlight N+1 cards.', + configPath: 'ankiConnect.nPlusOne.enabled', + category: 'mining-anki', + section: 'N+1', + control: 'boolean', + defaultValue: true, + restartBehavior: 'hot-reload', + }, + ]; + + assert.deepEqual( + filterSettingsFields(nPlusOneFields, { query: 'n+1' }).map((field) => field.configPath), + ['ankiConnect.nPlusOne.enabled'], + ); +}); + test('settings draft tracks dirty set and emits save operations', () => { const draft = createSettingsDraft({ 'subtitleStyle.autoPauseVideoOnHover': true, diff --git a/src/settings/settings-model.ts b/src/settings/settings-model.ts index 1d7bc5af..8228c970 100644 --- a/src/settings/settings-model.ts +++ b/src/settings/settings-model.ts @@ -38,7 +38,7 @@ export function filterSettingsFields( filter: SettingsFilter, ): ConfigSettingsField[] { const query = normalizeQuery(filter.query); - const terms = query.length > 0 ? query.split(/\s+/) : []; + const terms = query.length > 0 ? searchableText([query]).split(/\s+/).filter(Boolean) : []; return fields.filter((field) => { if (field.legacyHidden || field.settingsHidden) { return false; @@ -46,7 +46,7 @@ export function filterSettingsFields( if (filter.category && field.category !== filter.category) { return false; } - if (!query) { + if (!query || terms.length === 0) { return true; } const haystack = searchableText([ diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 1c998ca8..d85ff7b5 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -32,7 +32,6 @@ const CATEGORY_LABELS: Record = { appearance: 'Appearance', behavior: 'Behavior', 'mining-anki': 'Mining & Anki', - 'playback-sources': 'Playback & Sources', input: 'Input', integrations: 'Integrations', 'tracking-app': 'Tracking & App', @@ -43,7 +42,6 @@ const CATEGORY_ORDER: ConfigSettingsCategory[] = [ 'appearance', 'behavior', 'mining-anki', - 'playback-sources', 'input', 'integrations', 'tracking-app', diff --git a/src/settings/subtitle-style-css.test.ts b/src/settings/subtitle-style-css.test.ts index 5553526d..6403ac34 100644 --- a/src/settings/subtitle-style-css.test.ts +++ b/src/settings/subtitle-style-css.test.ts @@ -7,12 +7,16 @@ import { serializeSubtitleCssDeclarations, } from './subtitle-style-css'; -test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => { +test('serializeSubtitleCssDeclarations builds primary CSS from all managed appearance config', () => { const css = serializeSubtitleCssDeclarations('primary', { 'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif', 'subtitleStyle.fontSize': 35, 'subtitleStyle.fontColor': '#cad3f5', 'subtitleStyle.backgroundColor': 'transparent', + 'subtitleStyle.hoverTokenColor': '#f4dbd6', + 'subtitleStyle.hoverTokenBackgroundColor': 'rgba(54, 58, 79, 0.84)', + 'subtitleStyle.paintOrder': 'stroke fill', + 'subtitleStyle.WebkitTextStroke': '1.5px #000', 'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)', 'subtitleStyle.css': { filter: 'drop-shadow(0 0 8px #000)', @@ -22,11 +26,21 @@ test('serializeSubtitleCssDeclarations builds primary CSS from config minus defa assert.match(css, /font-family: M PLUS 1, sans-serif;/); assert.match(css, /font-size: 35px;/); + assert.match(css, /color: #cad3f5;/); + assert.match(css, /background-color: transparent;/); + assert.match(css, /--subtitle-hover-token-color: #f4dbd6;/); + assert.match(css, /--subtitle-hover-token-background-color: rgba\(54, 58, 79, 0.84\);/); + assert.match(css, /paint-order: stroke fill;/); + assert.match(css, /-webkit-text-stroke: 1.5px #000;/); + assert.doesNotMatch(css, /--subtitle-known-word-color:/); + assert.doesNotMatch(css, /--subtitle-n-plus-one-color:/); + assert.doesNotMatch(css, /--subtitle-name-match-color:/); + assert.doesNotMatch(css, /--subtitle-jlpt-n1-color:/); + assert.doesNotMatch(css, /--subtitle-frequency-single-color:/); + assert.doesNotMatch(css, /--subtitle-frequency-band-1-color:/); assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/); assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/); assert.match(css, /--subtitle-outline: 1px;/); - assert.doesNotMatch(css, /^color:/m); - assert.doesNotMatch(css, /^background-color:/m); }); test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => { @@ -42,9 +56,40 @@ test('serializeSubtitleCssDeclarations builds secondary CSS from secondary confi assert.match(css, /font-family: Noto Sans, sans-serif;/); assert.match(css, /font-size: 24px;/); + assert.match(css, /color: #cad3f5;/); + assert.match(css, /background-color: transparent;/); assert.match(css, /text-transform: uppercase;/); - assert.doesNotMatch(css, /^color:/m); - assert.doesNotMatch(css, /^background-color:/m); +}); + +test('serializeSubtitleCssDeclarations builds sidebar CSS from subtitle sidebar config paths', () => { + const css = serializeSubtitleCssDeclarations('sidebar', { + 'subtitleSidebar.fontFamily': 'M PLUS 1, sans-serif', + 'subtitleSidebar.fontSize': 16, + 'subtitleSidebar.textColor': '#cad3f5', + 'subtitleSidebar.backgroundColor': 'rgba(73, 77, 100, 0.9)', + 'subtitleSidebar.opacity': 0.95, + 'subtitleSidebar.maxWidth': 420, + 'subtitleSidebar.timestampColor': '#a5adcb', + 'subtitleSidebar.activeLineColor': '#f5bde6', + 'subtitleSidebar.activeLineBackgroundColor': 'rgba(138, 173, 244, 0.22)', + 'subtitleSidebar.hoverLineBackgroundColor': 'rgba(54, 58, 79, 0.84)', + 'subtitleSidebar.css': { + 'font-size': '18px', + 'text-wrap': 'pretty', + }, + }); + + assert.match(css, /font-family: M PLUS 1, sans-serif;/); + assert.match(css, /font-size: 18px;/); + assert.match(css, /color: #cad3f5;/); + assert.match(css, /background-color: rgba\(73, 77, 100, 0.9\);/); + assert.match(css, /opacity: 0.95;/); + assert.match(css, /--subtitle-sidebar-max-width: 420px;/); + assert.match(css, /--subtitle-sidebar-timestamp-color: #a5adcb;/); + assert.match(css, /--subtitle-sidebar-active-line-color: #f5bde6;/); + assert.match(css, /--subtitle-sidebar-active-background-color: rgba\(138, 173, 244, 0.22\);/); + assert.match(css, /--subtitle-sidebar-hover-background-color: rgba\(54, 58, 79, 0.84\);/); + assert.match(css, /text-wrap: pretty;/); }); test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => { @@ -72,17 +117,77 @@ test('parseSubtitleCssDeclarations rejects selectors and malformed declarations' assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false); }); -test('getSubtitleCssManagedConfigPaths excludes color controls', () => { +test('getSubtitleCssManagedConfigPaths includes all CSS-editor-owned appearance controls', () => { + assert.ok(!getSubtitleCssManagedConfigPaths('primary').includes('')); + assert.ok(!getSubtitleCssManagedConfigPaths('secondary').includes('')); + assert.ok(!getSubtitleCssManagedConfigPaths('sidebar').includes('')); assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize')); assert.ok( getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'), ); + assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.fontSize')); + assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.textColor')); + assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.maxWidth')); assert.equal( getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'), - false, + true, ); assert.equal( getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.backgroundColor'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('secondary').includes( + 'subtitleStyle.secondary.backgroundColor', + ), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenColor'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenBackgroundColor'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.paintOrder'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.WebkitTextStroke'), + true, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.knownWordColor'), + false, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nPlusOneColor'), + false, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nameMatchColor'), + false, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.jlptColors.N1'), + false, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes( + 'subtitleStyle.frequencyDictionary.singleColor', + ), + false, + ); + assert.equal( + getSubtitleCssManagedConfigPaths('primary').includes( + 'subtitleStyle.frequencyDictionary.bandedColors', + ), false, ); }); diff --git a/src/settings/subtitle-style-css.ts b/src/settings/subtitle-style-css.ts index 1f0a23f6..0fd60dea 100644 --- a/src/settings/subtitle-style-css.ts +++ b/src/settings/subtitle-style-css.ts @@ -1,11 +1,10 @@ import type { ConfigSettingsSnapshotValue } from '../types/settings'; -export type SubtitleCssScope = 'primary' | 'secondary'; +export type SubtitleCssScope = 'primary' | 'secondary' | 'sidebar'; type LegacyCssDeclaration = { property: string; - primaryPath: string; - secondaryPath: string; + paths: Partial>; format?: (value: unknown) => string | undefined; }; @@ -16,87 +15,187 @@ export type SubtitleCssParseResult = const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [ { property: 'font-family', - primaryPath: 'subtitleStyle.fontFamily', - secondaryPath: 'subtitleStyle.secondary.fontFamily', + paths: { + primary: 'subtitleStyle.fontFamily', + secondary: 'subtitleStyle.secondary.fontFamily', + sidebar: 'subtitleSidebar.fontFamily', + }, + }, + { + property: 'color', + paths: { + primary: 'subtitleStyle.fontColor', + secondary: 'subtitleStyle.secondary.fontColor', + sidebar: 'subtitleSidebar.textColor', + }, + }, + { + property: 'background-color', + paths: { + primary: 'subtitleStyle.backgroundColor', + secondary: 'subtitleStyle.secondary.backgroundColor', + sidebar: 'subtitleSidebar.backgroundColor', + }, }, { property: 'font-size', - primaryPath: 'subtitleStyle.fontSize', - secondaryPath: 'subtitleStyle.secondary.fontSize', + paths: { + primary: 'subtitleStyle.fontSize', + secondary: 'subtitleStyle.secondary.fontSize', + sidebar: 'subtitleSidebar.fontSize', + }, format: formatCssLengthLikeValue, }, { property: 'font-weight', - primaryPath: 'subtitleStyle.fontWeight', - secondaryPath: 'subtitleStyle.secondary.fontWeight', + paths: { + primary: 'subtitleStyle.fontWeight', + secondary: 'subtitleStyle.secondary.fontWeight', + }, }, { property: 'font-style', - primaryPath: 'subtitleStyle.fontStyle', - secondaryPath: 'subtitleStyle.secondary.fontStyle', + paths: { + primary: 'subtitleStyle.fontStyle', + secondary: 'subtitleStyle.secondary.fontStyle', + }, }, { property: 'line-height', - primaryPath: 'subtitleStyle.lineHeight', - secondaryPath: 'subtitleStyle.secondary.lineHeight', + paths: { + primary: 'subtitleStyle.lineHeight', + secondary: 'subtitleStyle.secondary.lineHeight', + }, }, { property: 'letter-spacing', - primaryPath: 'subtitleStyle.letterSpacing', - secondaryPath: 'subtitleStyle.secondary.letterSpacing', + paths: { + primary: 'subtitleStyle.letterSpacing', + secondary: 'subtitleStyle.secondary.letterSpacing', + }, }, { property: 'word-spacing', - primaryPath: 'subtitleStyle.wordSpacing', - secondaryPath: 'subtitleStyle.secondary.wordSpacing', + paths: { + primary: 'subtitleStyle.wordSpacing', + secondary: 'subtitleStyle.secondary.wordSpacing', + }, }, { property: 'font-kerning', - primaryPath: 'subtitleStyle.fontKerning', - secondaryPath: 'subtitleStyle.secondary.fontKerning', + paths: { + primary: 'subtitleStyle.fontKerning', + secondary: 'subtitleStyle.secondary.fontKerning', + }, }, { property: 'text-rendering', - primaryPath: 'subtitleStyle.textRendering', - secondaryPath: 'subtitleStyle.secondary.textRendering', + paths: { + primary: 'subtitleStyle.textRendering', + secondary: 'subtitleStyle.secondary.textRendering', + }, }, { property: 'text-shadow', - primaryPath: 'subtitleStyle.textShadow', - secondaryPath: 'subtitleStyle.secondary.textShadow', + paths: { + primary: 'subtitleStyle.textShadow', + secondary: 'subtitleStyle.secondary.textShadow', + }, + }, + { + property: 'paint-order', + paths: { + primary: 'subtitleStyle.paintOrder', + secondary: 'subtitleStyle.secondary.paintOrder', + }, + }, + { + property: '-webkit-text-stroke', + paths: { + primary: 'subtitleStyle.WebkitTextStroke', + secondary: 'subtitleStyle.secondary.WebkitTextStroke', + }, }, { property: 'backdrop-filter', - primaryPath: 'subtitleStyle.backdropFilter', - secondaryPath: 'subtitleStyle.secondary.backdropFilter', + paths: { + primary: 'subtitleStyle.backdropFilter', + secondary: 'subtitleStyle.secondary.backdropFilter', + }, }, { - property: 'color', - primaryPath: 'subtitleStyle.fontColor', - secondaryPath: 'subtitleStyle.secondary.fontColor', + property: '--subtitle-hover-token-color', + paths: { + primary: 'subtitleStyle.hoverTokenColor', + }, }, { - property: 'background-color', - primaryPath: 'subtitleStyle.backgroundColor', - secondaryPath: 'subtitleStyle.secondary.backgroundColor', + property: '--subtitle-hover-token-background-color', + paths: { + primary: 'subtitleStyle.hoverTokenBackgroundColor', + }, + }, + { + property: 'opacity', + paths: { + sidebar: 'subtitleSidebar.opacity', + }, + }, + { + property: '--subtitle-sidebar-max-width', + paths: { + sidebar: 'subtitleSidebar.maxWidth', + }, + format: formatCssLengthLikeValue, + }, + { + property: '--subtitle-sidebar-timestamp-color', + paths: { + sidebar: 'subtitleSidebar.timestampColor', + }, + }, + { + property: '--subtitle-sidebar-active-line-color', + paths: { + sidebar: 'subtitleSidebar.activeLineColor', + }, + }, + { + property: '--subtitle-sidebar-active-background-color', + paths: { + sidebar: 'subtitleSidebar.activeLineBackgroundColor', + }, + }, + { + property: '--subtitle-sidebar-hover-background-color', + paths: { + sidebar: 'subtitleSidebar.hoverLineBackgroundColor', + }, }, ]; const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/; export function getSubtitleCssPath(scope: SubtitleCssScope): string { - return scope === 'primary' ? 'subtitleStyle.css' : 'subtitleStyle.secondary.css'; + if (scope === 'primary') return 'subtitleStyle.css'; + if (scope === 'secondary') return 'subtitleStyle.secondary.css'; + return 'subtitleSidebar.css'; } export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] { - return LEGACY_CSS_DECLARATIONS.map((declaration) => - scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath, - ); + return [ + ...new Set( + LEGACY_CSS_DECLARATIONS.map((declaration) => declaration.paths[scope]).filter( + (path): path is string => typeof path === 'string' && path.length > 0, + ), + ), + ]; } export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null { if (path === 'subtitleStyle.css') return 'primary'; if (path === 'subtitleStyle.secondary.css') return 'secondary'; + if (path === 'subtitleSidebar.css') return 'sidebar'; return null; } @@ -104,10 +203,20 @@ export function serializeSubtitleCssDeclarations( scope: SubtitleCssScope, values: Record, ): string { + return Object.entries(buildSubtitleCssDeclarationObject(scope, values)) + .map(([property, value]) => `${property}: ${value};`) + .join('\n'); +} + +export function buildSubtitleCssDeclarationObject( + scope: SubtitleCssScope, + values: Record, +): Record { const declarations = new Map(); for (const declaration of LEGACY_CSS_DECLARATIONS) { - const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath; + const path = declaration.paths[scope]; + if (typeof path !== 'string' || path.length === 0) continue; const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]); if (formatted !== undefined) { declarations.set(declaration.property, formatted); @@ -119,9 +228,7 @@ export function serializeSubtitleCssDeclarations( declarations.set(normalizeCssPropertyName(property), value); } - return [...declarations.entries()] - .map(([property, value]) => `${property}: ${value};`) - .join('\n'); + return Object.fromEntries(declarations.entries()); } export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult { diff --git a/src/shared/mpv-socket-path.ts b/src/shared/mpv-socket-path.ts new file mode 100644 index 00000000..4f9256f5 --- /dev/null +++ b/src/shared/mpv-socket-path.ts @@ -0,0 +1,3 @@ +export function getDefaultMpvSocketPath(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket'; +} diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts new file mode 100644 index 00000000..3ba2eacf --- /dev/null +++ b/src/shared/subminer-plugin-script-opts.ts @@ -0,0 +1,37 @@ +import type { MpvBackend } from '../types/config'; + +export interface SubminerPluginRuntimeScriptOptConfig { + socketPath: string; + binaryPath?: string; + backend: MpvBackend; + autoStart: boolean; + autoStartVisibleOverlay: boolean; + autoStartPauseUntilReady: boolean; + texthookerEnabled: boolean; + aniskipEnabled: boolean; + aniskipButtonKey: string; +} + +function boolScriptOpt(value: boolean): 'yes' | 'no' { + return value ? 'yes' : 'no'; +} + +export function buildSubminerPluginRuntimeScriptOptParts( + runtimeConfig: SubminerPluginRuntimeScriptOptConfig, + fallbackAppPath: string, +): string[] { + const binaryPath = runtimeConfig.binaryPath?.trim() || fallbackAppPath; + return [ + `subminer-binary_path=${binaryPath}`, + `subminer-socket_path=${runtimeConfig.socketPath}`, + `subminer-backend=${runtimeConfig.backend}`, + `subminer-auto_start=${boolScriptOpt(runtimeConfig.autoStart)}`, + `subminer-auto_start_visible_overlay=${boolScriptOpt(runtimeConfig.autoStartVisibleOverlay)}`, + `subminer-auto_start_pause_until_ready=${boolScriptOpt( + runtimeConfig.autoStartPauseUntilReady, + )}`, + `subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`, + `subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`, + `subminer-aniskip_button_key=${runtimeConfig.aniskipButtonKey}`, + ]; +} diff --git a/src/types/config.ts b/src/types/config.ts index 808084b4..caca4711 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -30,6 +30,7 @@ import type { FrequencyDictionaryMatchMode, FrequencyDictionaryMode, NPlusOneMatchMode, + ResolvedSubtitleSidebarConfig, SecondarySubConfig, SubtitlePosition, SubtitleSidebarConfig, @@ -52,10 +53,18 @@ export interface TexthookerConfig { } export type MpvLaunchMode = 'normal' | 'maximized' | 'fullscreen'; +export type MpvBackend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; export interface MpvConfig { executablePath?: string; launchMode?: MpvLaunchMode; + socketPath?: string; + backend?: MpvBackend; + autoStartSubMiner?: boolean; + pauseUntilOverlayReady?: boolean; + subminerBinaryPath?: string; + aniskipEnabled?: boolean; + aniskipButtonKey?: string; } export type SubsyncMode = 'auto' | 'manual'; @@ -150,6 +159,13 @@ export interface ResolvedConfig { mpv: { executablePath: string; launchMode: MpvLaunchMode; + socketPath: string; + backend: MpvBackend; + autoStartSubMiner: boolean; + pauseUntilOverlayReady: boolean; + subminerBinaryPath: string; + aniskipEnabled: boolean; + aniskipButtonKey: string; }; controller: { enabled: boolean; @@ -260,7 +276,7 @@ export interface ResolvedConfig { bandedColors: [string, string, string, string, string]; }; }; - subtitleSidebar: Required; + subtitleSidebar: ResolvedSubtitleSidebarConfig; auto_start_overlay: boolean; jimaku: JimakuConfig & { apiBaseUrl: string; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 3deb6005..188cef24 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -26,10 +26,10 @@ import type { } from './integrations'; import type { PrimarySubMode, + ResolvedSubtitleSidebarConfig, SecondarySubMode, SubtitleData, SubtitlePosition, - SubtitleSidebarConfig, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, SubtitleStyleConfig, @@ -345,7 +345,7 @@ export interface ConfigHotReloadPayload { sessionBindings: CompiledSessionBinding[]; sessionBindingWarnings: SessionBindingWarning[]; subtitleStyle: SubtitleRendererStyleConfig | null; - subtitleSidebar: Required; + subtitleSidebar: ResolvedSubtitleSidebarConfig; primarySubMode: PrimarySubMode; secondarySubMode: SecondarySubMode; } diff --git a/src/types/settings.ts b/src/types/settings.ts index 249eb584..39b9862b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -4,7 +4,6 @@ export type ConfigSettingsCategory = | 'appearance' | 'behavior' | 'mining-anki' - | 'playback-sources' | 'input' | 'integrations' | 'tracking-app' @@ -22,6 +21,7 @@ export type ConfigSettingsControl = | 'secret' | 'keyboard-shortcut' | 'key-code' + | 'mpv-key' | 'known-words-decks' | 'anki-note-type' | 'anki-field' diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 13e5c6ab..59b2dd41 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -90,6 +90,8 @@ export interface SubtitleStyleConfig { fontKerning?: string; textRendering?: string; textShadow?: string; + paintOrder?: string; + WebkitTextStroke?: string; backdropFilter?: string; backgroundColor?: string; nPlusOneColor?: string; @@ -123,6 +125,8 @@ export interface SubtitleStyleConfig { fontKerning?: string; textRendering?: string; textShadow?: string; + paintOrder?: string; + WebkitTextStroke?: string; backdropFilter?: string; backgroundColor?: string; }; @@ -167,6 +171,7 @@ export interface SubtitleSidebarConfig { toggleKey?: string; pauseVideoOnHover?: boolean; autoScroll?: boolean; + css?: Record; maxWidth?: number; opacity?: number; backgroundColor?: string; @@ -179,6 +184,14 @@ export interface SubtitleSidebarConfig { hoverLineBackgroundColor?: string; } +export type ResolvedSubtitleSidebarConfig = Required> & { + css: Record; +}; + +export type SubtitleSidebarSnapshotConfig = Required> & { + css?: Record; +}; + export interface SubtitleData { text: string; tokens: MergedToken[] | null; @@ -194,7 +207,7 @@ export interface SubtitleSidebarSnapshot { startTime: number | null; endTime: number | null; }; - config: Required; + config: SubtitleSidebarSnapshotConfig; } export interface SubtitleHoverTokenPayload { diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index 4dcedaa6..c7dafade 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -1,7 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import type { PathLike } from 'node:fs'; import test from 'node:test'; import { isCompiledMacOSHelperCurrent, @@ -34,27 +32,22 @@ test('parseMacOSHelperOutput parses inactive state without geometry', () => { }); test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => { - const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-')); - try { - const binaryPath = join(tempDir, 'get-mpv-window-macos'); - const sourcePath = join(tempDir, 'get-mpv-window-macos.swift'); - writeFileSync(binaryPath, 'binary'); - writeFileSync(sourcePath, 'source'); + const binaryPath = '/tmp/get-mpv-window-macos'; + const sourcePath = '/tmp/get-mpv-window-macos.swift'; + const statSync = (binaryMtimeMs: number, sourceMtimeMs: number) => (targetPath: PathLike) => + ({ + mtimeMs: String(targetPath) === binaryPath ? binaryMtimeMs : sourceMtimeMs, + }) as never; + const helperFs = { + existsSync: () => true, + statSync: statSync(1_000, 2_000), + }; - const older = new Date('2026-01-01T00:00:00Z'); - const newer = new Date('2026-01-01T00:00:05Z'); - utimesSync(binaryPath, older, older); - utimesSync(sourcePath, newer, newer); + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath, helperFs), false); - assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false); + helperFs.statSync = statSync(2_000, 1_000); - utimesSync(binaryPath, newer, newer); - utimesSync(sourcePath, older, older); - - assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath, helperFs), true); }); test('MacOSWindowTracker slows polling while focused target is stable', async () => {