mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc - Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config - Add subtitleSidebar.css field; migrate legacy sidebar appearance fields - Add paintOrder and WebkitTextStroke to subtitle style options - Update default subtitle/sidebar fontFamily to CJK-first stack - Fix overlay visible state surviving mpv y-r restart - Fix live config saves applying subtitle CSS immediately to open overlays - Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load - Switch AniSkip button key setting to click-to-learn key capture
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
|
||||
@@ -0,0 +1 @@
|
||||
feat: manage bundled mpv plugin startup options from SubMiner config
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Added `subtitleSidebar.css`, migrated legacy sidebar appearance fields into it, and updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||
+23
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): 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: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
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) {
|
||||
|
||||
@@ -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<typeof state.mpvProc>,
|
||||
});
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, true);
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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<string, unknown>): LauncherMpvConfig {
|
||||
const mpvRaw = root.mpv;
|
||||
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||
@@ -8,5 +31,15 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | null, key: string): Record<string, unknown> {
|
||||
const value = root?.[key];
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
+36
-15
@@ -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,
|
||||
|
||||
+21
-9
@@ -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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
|
||||
+11
-13
@@ -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);
|
||||
|
||||
+15
-5
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test: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",
|
||||
|
||||
+3
-75
@@ -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.
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+158
-1
@@ -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<string, string>;
|
||||
secondary?: {
|
||||
fontSize?: unknown;
|
||||
fontColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
subtitleSidebar: {
|
||||
fontFamily?: unknown;
|
||||
fontSize?: unknown;
|
||||
textColor?: unknown;
|
||||
timestampColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(parsed.subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
@@ -45,7 +47,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
},
|
||||
secondary: {
|
||||
css: {},
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 24,
|
||||
fontColor: '#cad3f5',
|
||||
lineHeight: 1.35,
|
||||
@@ -54,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: '600',
|
||||
@@ -67,11 +71,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
maxWidth: 420,
|
||||
opacity: 0.95,
|
||||
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 16,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
|
||||
@@ -65,6 +65,7 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = 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<string> = 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',
|
||||
]) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+34
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<string, number>(
|
||||
'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<string, number>(
|
||||
'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<string, string> = {
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
'subtitleStyle.secondary.css': 'CSS Declarations',
|
||||
'subtitleSidebar.css': 'CSS Declarations',
|
||||
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
||||
'subtitlePosition.yPercent': 'Subtitle Position',
|
||||
'mpv.executablePath': 'mpv Executable Path',
|
||||
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
|
||||
'mpv.socketPath': 'mpv IPC Socket Path',
|
||||
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
|
||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
};
|
||||
|
||||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
@@ -196,6 +221,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleStyle.secondary.css':
|
||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasPath(root: unknown, path: string): boolean {
|
||||
let current = root;
|
||||
const segments = path.split('.');
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
||||
return false;
|
||||
}
|
||||
if (index === segments.length - 1) {
|
||||
return true;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||
rawConfig: RawConfig,
|
||||
): ConfigSettingsPatchOperation[] {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(rawConfig, cssPath),
|
||||
};
|
||||
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
|
||||
(legacyPath) =>
|
||||
!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,
|
||||
};
|
||||
}
|
||||
+48
-11
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<TWindow extends ConfigSettingsWindowL
|
||||
getConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
@@ -122,6 +126,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
reloadConfigStrict: () => 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<TWindow extends ConfigSettingsWindow
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
||||
(_event, deckName, draftUrl) =>
|
||||
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.');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
}): Promise<SubtitleData> {
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
}
|
||||
|
||||
if (!deps.currentSubText.trim()) {
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, (payload: unknown) => 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'));
|
||||
});
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
quitApp: () => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onMpvConnected?: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
|
||||
);
|
||||
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
||||
});
|
||||
|
||||
+25
-21
@@ -161,29 +161,39 @@ const onKikuFieldGroupingRequestEvent =
|
||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||
(payload) => payload as KikuFieldGroupingRequestData,
|
||||
);
|
||||
const onSubtitleSetEvent = createQueuedIpcListenerWithPayload<SubtitleData>(
|
||||
IPC_CHANNELS.event.subtitleSet,
|
||||
(payload) => payload as SubtitleData,
|
||||
);
|
||||
const onSubtitleVisibilityEvent = createQueuedIpcListenerWithPayload<boolean>(
|
||||
IPC_CHANNELS.event.subtitleVisibility,
|
||||
(payload) => payload === true,
|
||||
);
|
||||
const onSubtitlePositionSetEvent = createQueuedIpcListenerWithPayload<SubtitlePosition | null>(
|
||||
IPC_CHANNELS.event.subtitlePositionSet,
|
||||
(payload) => payload as SubtitlePosition | null,
|
||||
);
|
||||
const onSecondarySubtitleSetEvent = createQueuedIpcListenerWithPayload<string>(
|
||||
IPC_CHANNELS.event.secondarySubtitleSet,
|
||||
(payload) => (typeof payload === 'string' ? payload : ''),
|
||||
);
|
||||
const onSecondarySubtitleModeEvent = createQueuedIpcListenerWithPayload<SecondarySubMode>(
|
||||
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<boolean> =>
|
||||
@@ -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<SecondarySubMode> =>
|
||||
|
||||
@@ -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<Array<string | number>> = [];
|
||||
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
|
||||
let sessionBindings: CompiledSessionBinding[] = [];
|
||||
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = 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<string | number>) => {
|
||||
mpvCommands.push(command);
|
||||
@@ -366,6 +369,9 @@ function installKeyboardTestGlobals() {
|
||||
setSessionBindings: (value: CompiledSessionBinding[]) => {
|
||||
sessionBindings = value;
|
||||
},
|
||||
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
|
||||
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<CompiledSessionBinding[]>(() => {}));
|
||||
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();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createKeyboardHandlers(
|
||||
actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple';
|
||||
timeout: ReturnType<typeof setTimeout> | null;
|
||||
} | null = null;
|
||||
let mpvInputForwardingListenersInstalled = false;
|
||||
|
||||
const CHORD_MAP = new Map<
|
||||
string,
|
||||
@@ -940,7 +941,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
async function setupMpvInputForwarding(): Promise<void> {
|
||||
async function loadMpvInputForwardingConfig(): Promise<void> {
|
||||
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<void> {
|
||||
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<void>((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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, string>();
|
||||
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']);
|
||||
|
||||
@@ -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<string, string>,
|
||||
): 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<string, string>;
|
||||
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 {
|
||||
|
||||
@@ -613,6 +613,8 @@ async function init(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
let initialSubtitle: SubtitleData | string = '';
|
||||
try {
|
||||
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
||||
@@ -698,8 +700,6 @@ async function init(): Promise<void> {
|
||||
});
|
||||
});
|
||||
mouseHandlers.setupDragging();
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
try {
|
||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<SubtitleSidebarConfig> | null;
|
||||
subtitleSidebarConfig: SubtitleSidebarSnapshotConfig | null;
|
||||
subtitleSidebarManualScrollUntilMs: number;
|
||||
subtitleSidebarPausedByHover: boolean;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
Tab: 'Tab',
|
||||
};
|
||||
|
||||
const MPV_KEY_BY_CODE: Record<string, string> = {
|
||||
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');
|
||||
|
||||
@@ -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'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -32,7 +32,6 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<Record<SubtitleCssScope, string>>;
|
||||
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, ConfigSettingsSnapshotValue | undefined>,
|
||||
): string {
|
||||
return Object.entries(buildSubtitleCssDeclarationObject(scope, values))
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSubtitleCssDeclarationObject(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): Record<string, string> {
|
||||
const declarations = new Map<string, string>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getDefaultMpvSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
|
||||
}
|
||||
@@ -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}`,
|
||||
];
|
||||
}
|
||||
+17
-1
@@ -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<SubtitleSidebarConfig>;
|
||||
subtitleSidebar: ResolvedSubtitleSidebarConfig;
|
||||
auto_start_overlay: boolean;
|
||||
jimaku: JimakuConfig & {
|
||||
apiBaseUrl: string;
|
||||
|
||||
@@ -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<SubtitleSidebarConfig>;
|
||||
subtitleSidebar: ResolvedSubtitleSidebarConfig;
|
||||
primarySubMode: PrimarySubMode;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
+14
-1
@@ -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<string, string>;
|
||||
maxWidth?: number;
|
||||
opacity?: number;
|
||||
backgroundColor?: string;
|
||||
@@ -179,6 +184,14 @@ export interface SubtitleSidebarConfig {
|
||||
hoverLineBackgroundColor?: string;
|
||||
}
|
||||
|
||||
export type ResolvedSubtitleSidebarConfig = Required<Omit<SubtitleSidebarConfig, 'css'>> & {
|
||||
css: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SubtitleSidebarSnapshotConfig = Required<Omit<SubtitleSidebarConfig, 'css'>> & {
|
||||
css?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface SubtitleData {
|
||||
text: string;
|
||||
tokens: MergedToken[] | null;
|
||||
@@ -194,7 +207,7 @@ export interface SubtitleSidebarSnapshot {
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
};
|
||||
config: Required<SubtitleSidebarConfig>;
|
||||
config: SubtitleSidebarSnapshotConfig;
|
||||
}
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user