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
+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 };