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:
2026-05-17 18:01:39 -07:00
parent 0354a0e74b
commit 1ff44e0d69
91 changed files with 2241 additions and 727 deletions
+1
View File
@@ -0,0 +1 @@
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
+1
View File
@@ -0,0 +1 @@
feat: manage bundled mpv plugin startup options from SubMiner config
+1 -1
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
+4
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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
+23 -8
View File
@@ -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
+4 -2
View File
@@ -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: {},
+7 -1
View File
@@ -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) {
+13 -3
View File
@@ -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;
+5 -2
View File
@@ -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 {
+77 -30
View File
@@ -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',
],
);
});
+1 -1
View File
@@ -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: '',
+33
View File
@@ -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),
};
}
+55 -105
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+69 -108
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4 -4
View 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 = "",
+7 -2
View File
@@ -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
+64
View File
@@ -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
View File
@@ -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,
+7 -2
View File
@@ -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',
]) {
+2 -1
View File
@@ -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',
+49 -1
View File
@@ -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',
+6 -3
View File
@@ -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.',
],
+91
View File
@@ -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.');
}
+13
View File
@@ -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
View File
@@ -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;
}
}
}
+62 -4
View File
@@ -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'],
+56 -20
View File
@@ -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;
+121
View File
@@ -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
View File
@@ -376,7 +376,6 @@ import {
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
syncInstalledFirstRunPluginBinaryPath,
} from './main/runtime/first-run-setup-plugin';
import {
applyWindowsMpvShortcuts,
@@ -495,6 +494,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,
@@ -667,7 +667,7 @@ const texthookerService = new Texthooker(() => {
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
config.ankiConnect.nPlusOne.enabled,
);
return {
@@ -1223,6 +1223,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),
@@ -1249,12 +1260,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,
@@ -1814,6 +1819,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,
@@ -2625,6 +2631,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) => {
@@ -4057,6 +4074,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);
@@ -5245,7 +5273,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 () => {
@@ -5582,7 +5614,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,
@@ -5648,7 +5680,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();
+2 -2
View File
@@ -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',
+18 -9
View File
@@ -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;
+4
View File
@@ -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 -79
View File
@@ -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;
+16 -1
View File
@@ -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();
+2 -4
View File
@@ -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(
+17 -4
View File
@@ -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 };
+10
View File
@@ -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
View File
@@ -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> =>
+36 -1
View File
@@ -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();
+38 -1
View File
@@ -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();
+13 -1
View File
@@ -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 -1
View File
@@ -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']);
+19
View File
@@ -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 {
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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;
+5 -5
View File
@@ -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;
+17
View File
@@ -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, []);
+46 -1
View File
@@ -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');
+23 -5
View File
@@ -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'),
'',
);
});
+44 -11
View File
@@ -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();
}
}
}
+4
View File
@@ -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);
}
+21
View File
@@ -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,
+2 -2
View File
@@ -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([
-2
View File
@@ -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',
+112 -7
View File
@@ -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,
);
});
+146 -39
View File
@@ -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 {
+3
View File
@@ -0,0 +1,3 @@
export function getDefaultMpvSocketPath(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
}
+37
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+14 -21
View File
@@ -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 () => {