Fix Windows mpv handoff and tray setup (#82)

This commit is contained in:
2026-05-25 01:34:01 -07:00
committed by GitHub
parent 17d97f0b7e
commit 920cbab1bc
31 changed files with 751 additions and 220 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ 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.socketPath, DEFAULT_CONFIG.mpv.socketPath);
assert.equal(config.mpv.backend, 'auto');
assert.equal(config.mpv.profile, '');
assert.equal(config.mpv.autoStartSubMiner, true);
+5 -1
View File
@@ -80,7 +80,11 @@ export {
handleOverlayWindowBeforeInputEvent,
isTabInputForMpvForwarding,
} from './overlay-window-input';
export { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
export {
initializeOverlayAnkiIntegration,
initializeOverlayRuntime,
startOverlayWindowTracker,
} from './overlay-runtime-init';
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
+60 -1
View File
@@ -1,6 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
import {
initializeOverlayAnkiIntegration,
initializeOverlayRuntime,
startOverlayWindowTracker,
} from './overlay-runtime-init';
test('startOverlayWindowTracker starts tracker for the current mpv socket', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
onWindowFound: null as ((...args: unknown[]) => void) | null,
onWindowLost: null as (() => void) | null,
onWindowFocusChange: null as ((focused: boolean) => void) | null,
isTargetWindowMinimized: () => false,
start: () => {
calls.push('start');
},
};
const result = startOverlayWindowTracker({
backendOverride: 'windows',
getMpvSocketPath: () => '\\\\.\\pipe\\subminer-socket',
createWindowTracker: (override, socketPath) => {
calls.push(`create:${override}:${socketPath}`);
return tracker as never;
},
setWindowTracker: (nextTracker) => {
calls.push(nextTracker === tracker ? 'set-tracker' : 'clear-tracker');
},
updateVisibleOverlayBounds: () => {
calls.push('bounds');
},
isVisibleOverlayVisible: () => true,
updateVisibleOverlayVisibility: () => {
calls.push('visibility');
},
refreshCurrentSubtitle: () => {
calls.push('refresh-subtitle');
},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
});
assert.equal(result, tracker);
tracker.onWindowFound?.({ x: 10, y: 20, width: 300, height: 200 });
tracker.onWindowFocusChange?.(true);
assert.deepEqual(calls, [
'create:windows:\\\\.\\pipe\\subminer-socket',
'set-tracker',
'start',
'bounds',
'visibility',
'refresh-subtitle',
'visibility',
'sync-shortcuts',
]);
});
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
let createdIntegrations = 0;
+83 -67
View File
@@ -25,6 +25,24 @@ type CreateAnkiIntegrationArgs = {
knownWordCacheStatePath: string;
};
export type OverlayWindowTrackerOptions = {
backendOverride: string | null;
getMpvSocketPath: () => string;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
};
function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike {
const { AnkiIntegration } =
require('../../anki-integration') as typeof import('../../anki-integration');
@@ -46,82 +64,80 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
);
}
export function initializeOverlayRuntime(options: {
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => {
send?: (payload: { command: string[] }) => void;
} | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
getAnkiIntegration?: () => unknown | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null;
createMainWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void;
refreshCurrentSubtitle?: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
bindOverlayOwner?: () => void;
releaseOverlayOwner?: () => void;
}): void {
options.createMainWindow();
options.registerGlobalShortcuts();
export function startOverlayWindowTracker(
options: OverlayWindowTrackerOptions,
): BaseWindowTracker | null {
const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker;
const windowTracker = createWindowTrackerHandler(
options.backendOverride,
options.getMpvSocketPath(),
);
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.bindOverlayOwner?.();
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
}
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
if (windowTracker.isTargetWindowMinimized()) {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
return;
}
if (!windowTracker) {
return null;
}
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
};
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateVisibleOverlayBounds(geometry);
options.bindOverlayOwner?.();
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
};
windowTracker.onWindowFocusChange = () => {
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
options.refreshCurrentSubtitle?.();
}
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
if (windowTracker.isTargetWindowMinimized()) {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
};
windowTracker.start();
}
return;
}
options.updateVisibleOverlayVisibility();
};
windowTracker.onWindowFocusChange = () => {
if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility();
}
options.syncOverlayShortcuts();
};
windowTracker.start();
return windowTracker;
}
export function initializeOverlayRuntime(
options: OverlayWindowTrackerOptions & {
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => {
send?: (payload: { command: string[] }) => void;
} | null;
getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
} | null;
getAnkiIntegration?: () => unknown | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
backendOverride: string | null;
createMainWindow: () => void;
registerGlobalShortcuts: () => void;
},
): void {
options.createMainWindow();
options.registerGlobalShortcuts();
startOverlayWindowTracker(options);
initializeOverlayAnkiIntegration(options);
+43
View File
@@ -0,0 +1,43 @@
import path from 'node:path';
import { loadRawConfigStrict } from './config/load';
import { resolveConfig } from './config/resolve';
import type { MpvLaunchMode, ResolvedConfig } from './types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from './shared/subminer-plugin-script-opts';
export interface ConfiguredWindowsMpvLaunch {
executablePath: string;
launchMode: MpvLaunchMode;
pluginRuntimeConfig: SubminerPluginRuntimeScriptOptConfig;
}
export function buildWindowsMpvPluginRuntimeConfig(
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'mpv' | 'texthooker'>,
): SubminerPluginRuntimeScriptOptConfig {
return {
socketPath: config.mpv.socketPath,
binaryPath: config.mpv.subminerBinaryPath,
backend: config.mpv.backend,
autoStart: config.mpv.autoStartSubMiner,
autoStartVisibleOverlay: config.auto_start_overlay,
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
texthookerEnabled: config.texthooker.launchAtStartup,
aniskipEnabled: config.mpv.aniskipEnabled,
aniskipButtonKey: config.mpv.aniskipButtonKey,
};
}
export function readConfiguredWindowsMpvLaunch(configDir: string): ConfiguredWindowsMpvLaunch {
const loadResult = loadRawConfigStrict({
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
});
const rawConfig = loadResult.ok ? loadResult.config : {};
const { resolved } = resolveConfig(rawConfig);
return {
executablePath: resolved.mpv.executablePath,
launchMode: resolved.mpv.launchMode,
pluginRuntimeConfig: buildWindowsMpvPluginRuntimeConfig(resolved),
};
}
+106 -2
View File
@@ -1,5 +1,10 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { DEFAULT_CONFIG } from './config/definitions';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
@@ -146,7 +151,7 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star
]);
});
test('transported AppImage visibility commands should forward through app control', () => {
test('transported AppImage visibility commands forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
SUBMINER_APP_ARGC: '1',
@@ -156,9 +161,35 @@ test('transported AppImage visibility commands should forward through app contro
);
});
test('app control forwarding is only for transported runtime commands', () => {
test('direct runtime commands forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
true,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(
['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer-socket'],
{},
),
true,
);
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--settings'], {}), true);
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stop'], {}), true);
});
test('entry-only and internal commands do not forward through app control', () => {
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats-daemon-start'], {}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--stats', '--stats-background'], {}),
false,
);
assert.equal(
@@ -175,6 +206,12 @@ test('app control forwarding is only for transported runtime commands', () => {
}),
false,
);
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--start'], {
ELECTRON_RUN_AS_NODE: '1',
}),
false,
);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
@@ -269,6 +306,73 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
);
});
test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script opts', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-'));
try {
const launch = readConfiguredWindowsMpvLaunch(tempDir);
assert.equal(launch.executablePath, DEFAULT_CONFIG.mpv.executablePath);
assert.equal(launch.launchMode, DEFAULT_CONFIG.mpv.launchMode);
assert.deepEqual(launch.pluginRuntimeConfig, {
socketPath: DEFAULT_CONFIG.mpv.socketPath,
binaryPath: DEFAULT_CONFIG.mpv.subminerBinaryPath,
backend: DEFAULT_CONFIG.mpv.backend,
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script opts', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-config-'));
try {
fs.writeFileSync(
path.join(tempDir, 'config.jsonc'),
JSON.stringify({
auto_start_overlay: false,
texthooker: {
launchAtStartup: true,
},
mpv: {
executablePath: ' C:\\tools\\mpv.exe ',
launchMode: 'maximized',
socketPath: '\\\\.\\pipe\\custom-subminer-socket',
backend: 'windows',
autoStartSubMiner: false,
pauseUntilOverlayReady: false,
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
aniskipEnabled: false,
aniskipButtonKey: 'F8',
},
}),
);
const launch = readConfiguredWindowsMpvLaunch(tempDir);
assert.equal(launch.executablePath, 'C:\\tools\\mpv.exe');
assert.equal(launch.launchMode, 'maximized');
assert.deepEqual(launch.pluginRuntimeConfig, {
socketPath: '\\\\.\\pipe\\custom-subminer-socket',
binaryPath: 'C:\\SubMiner\\Custom.exe',
backend: 'windows',
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
texthookerEnabled: true,
aniskipEnabled: false,
aniskipButtonKey: 'F8',
});
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('stats-daemon entry helper detects internal daemon commands', () => {
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {}),
+1 -2
View File
@@ -154,10 +154,9 @@ export function shouldForwardStartupArgvViaAppControl(
env: NodeJS.ProcessEnv,
): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!hasTransportedStartupArgs(env)) return false;
const args = parseCliArgs(argv);
if (args.help || args.appPing || args.launchMpv) return false;
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
return hasExplicitCommand(args);
+45 -65
View File
@@ -1,9 +1,7 @@
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
@@ -22,6 +20,7 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import { sendAppControlCommand } from './shared/app-control-client';
import {
detectInstalledFirstRunPluginCandidates,
@@ -30,7 +29,6 @@ import {
resolvePackagedRuntimePluginPath,
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
@@ -150,31 +148,6 @@ function createWindowsRuntimePluginPolicy() {
};
}
function readConfiguredWindowsMpvLaunch(configDir: string): {
executablePath: string;
launchMode: 'normal' | 'maximized' | 'fullscreen';
} {
const loadResult = loadRawConfigStrict({
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
});
if (!loadResult.ok) {
return {
executablePath: '',
launchMode: 'normal',
};
}
return {
executablePath:
typeof loadResult.config.mpv?.executablePath === 'string'
? loadResult.config.mpv.executablePath.trim()
: '',
launchMode: parseMpvLaunchMode(loadResult.config.mpv?.launchMode) ?? 'normal',
};
}
process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env));
@@ -226,31 +199,22 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
return false;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
}
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
delete process.env.VK_INSTANCE_LAYERS;
async function runEntryProcess(): Promise<void> {
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
delete process.env.VK_INSTANCE_LAYERS;
}
printHelp(DEFAULT_TEXTHOOKER_PORT);
process.exit(0);
return;
}
printHelp(DEFAULT_TEXTHOOKER_PORT);
process.exit(0);
}
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
void app.whenReady().then(async () => {
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
await app.whenReady();
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
@@ -266,23 +230,39 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
createWindowsRuntimePluginPolicy(),
configuredMpvLaunch.pluginRuntimeConfig,
);
app.exit(result.ok ? 0 : 1);
});
} else if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) {
void app.whenReady().then(async () => {
return;
}
if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) {
await app.whenReady();
const exitCode = await runStatsDaemonControlFromProcess(app.getPath('userData'));
app.exit(exitCode);
});
} else {
void forwardStartupArgvViaAppControlIfAvailable()
.then((forwarded) => {
if (!forwarded) {
startMainProcess();
}
})
.catch((error) => {
console.error('SubMiner app-control handoff failed:', error);
startMainProcess();
return;
}
if (await forwardStartupArgvViaAppControlIfAvailable()) {
return;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
return;
}
startMainProcess();
}
void runEntryProcess().catch((error) => {
console.error('SubMiner app-control handoff failed:', error);
startMainProcess();
});
+107 -45
View File
@@ -347,6 +347,7 @@ import {
syncOverlayWindowLayer,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
showMpvOsdRuntime,
startOverlayWindowTracker as startOverlayWindowTrackerCore,
tokenizeSubtitle as tokenizeSubtitleCore,
triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings,
@@ -2460,6 +2461,7 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
return trackedHandle;
}
return null;
}
return findWindowsMpvTargetWindowHandle();
} catch {
@@ -2467,6 +2469,104 @@ function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | nu
}
}
function createOverlayWindowTracker(override?: string | null, targetMpvSocketPath?: string | null) {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
}
function bindVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetSocketPath = appState.mpvSocketPath;
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
return;
}
if (targetSocketPath) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
const win32 =
require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
}
function releaseVisibleOverlayOwner(): void {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
}
function startOverlayWindowTrackerForCurrentSocket(): void {
startOverlayWindowTrackerCore({
backendOverride: appState.backendOverride,
getMpvSocketPath: () => appState.mpvSocketPath,
createWindowTracker: createOverlayWindowTracker,
setWindowTracker: (tracker) => {
appState.windowTracker = tracker;
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
getOverlayWindows: () => getOverlayWindows(),
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
bindOverlayOwner: () => bindVisibleOverlayOwner(),
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
});
}
function retargetOverlayWindowTrackerForMpvSocket(
nextSocketPath: string,
previousSocketPath: string,
): void {
if (nextSocketPath === previousSocketPath || !appState.overlayRuntimeInitialized) {
return;
}
const previousTracker = appState.windowTracker;
if (previousTracker) {
try {
previousTracker.stop();
} catch (error) {
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
}
}
releaseVisibleOverlayOwner();
appState.windowTracker = null;
appState.trackerNotReadyWarningShown = false;
lastOverlayWindowGeometry = null;
startOverlayWindowTrackerForCurrentSocket();
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
overlayShortcutsRuntime.syncOverlayShortcuts();
logger.info(
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
);
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
@@ -5925,6 +6025,8 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
cliCommandContextMainDeps: {
appState,
setLogLevel: (level) => setLogLevel(level, 'cli'),
onMpvSocketPathChanged: (nextSocketPath, previousSocketPath) =>
retargetOverlayWindowTrackerForMpvSocket(nextSocketPath, previousSocketPath),
texthookerService,
getResolvedConfig: () => getResolvedConfig(),
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
@@ -6213,7 +6315,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
@@ -6323,52 +6425,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
}
registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
},
createWindowTracker: (override, targetMpvSocketPath) =>
createOverlayWindowTracker(override, targetMpvSocketPath),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
updateVisibleOverlayBounds(geometry),
bindOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (
targetWindowHwnd !== null &&
bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)
) {
return;
}
const tracker = appState.windowTracker;
const mpvResult = tracker
? (() => {
try {
const win32 =
require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
} catch {
return null;
}
})()
: null;
if (!mpvResult) return;
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
logger.warn('Failed to set overlay owner via koffi');
}
},
releaseOverlayOwner: () => {
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (!clearWindowsOverlayOwner(overlayHwnd)) {
logger.warn('Failed to clear overlay owner via koffi');
}
},
bindOverlayOwner: () => bindVisibleOverlayOwner(),
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification,
@@ -13,6 +13,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
const build = createBuildCliCommandContextMainDepsHandler({
appState,
onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`),
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
@@ -121,6 +122,10 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
deps.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
deps.setSocketPath('/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
calls.length = 0;
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
@@ -12,6 +12,7 @@ type CliCommandContextMainState = {
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
@@ -74,7 +75,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setLogLevel: deps.setLogLevel,
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
const previousSocketPath = deps.appState.mpvSocketPath;
deps.appState.mpvSocketPath = socketPath;
if (socketPath !== previousSocketPath) {
deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath);
}
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
+29 -4
View File
@@ -139,13 +139,38 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
return detail ? `${fallback}: ${detail}` : fallback;
}
function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
function quoteForWindowsShell(value: string): string {
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
}
function createDefaultRunCommand(): RunCommand {
return (command, args, options = {}) =>
new Promise((resolve) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
const useShell = needsWindowsShell(command);
let child: ReturnType<typeof spawn>;
try {
child = useShell
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
env: options.env ?? process.env,
windowsHide: false,
shell: true,
})
: spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
} catch (error) {
resolve({
exitCode: 1,
stdout: '',
stderr: error instanceof Error ? error.message : String(error),
});
return;
}
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
detectBun,
@@ -9,6 +11,7 @@ import {
resolveLauncherInstallTarget,
type BunSnapshot,
} from './command-line-launcher';
import { getRunCommand } from './command-line-launcher-deps';
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
return {
@@ -85,6 +88,48 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
);
});
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
if (process.platform !== 'win32') return;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
const scriptPath = path.join(tempDir, 'argv.cmd');
const outputPath = path.join(tempDir, 'argv.txt');
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.writeFileSync(
scriptPath,
[
'@echo off',
'setlocal DisableDelayedExpansion',
'> "%SUBMINER_ARGV_OUT%" (',
' echo 1=%~1',
' echo 2=%~2',
' echo 3=%~3',
' echo 4=%~4',
' echo 5=%~5',
' echo 6=%~6',
')',
'',
].join('\r\n'),
'utf8',
);
const result = await getRunCommand({})(
scriptPath,
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
{
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(
fs.readFileSync(outputPath, 'utf8'),
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
assert.deepEqual(
resolveBunInstallCommand({
+39 -2
View File
@@ -66,7 +66,8 @@ test('build tray template handler wires actions and init guards', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
@@ -91,7 +92,7 @@ test('build tray template handler wires actions and init guards', () => {
'texthooker',
'show-texthooker:true',
'setup',
'setup',
'setup-forced',
'yomitan',
'configuration',
'jellyfin',
@@ -102,6 +103,42 @@ test('build tray template handler wires actions and init guards', () => {
]);
});
test('windows mpv launcher tray action force-opens completed setup', () => {
const calls: string[] = [];
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
assert.equal(handlers.showFirstRunSetup, false);
assert.equal(handlers.showWindowsMpvLauncherSetup, true);
handlers.openWindowsMpvLauncherSetup();
return [{ label: 'ok' }] as never;
},
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => true,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => false,
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {
calls.push('jellyfin-discovery');
},
platform: 'win32',
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
assert.deepEqual(buildTemplate(), [{ label: 'ok' }]);
assert.deepEqual(calls, ['setup-forced']);
});
test('texthooker tray visibility follows websocket server enabled state', () => {
assert.equal(
shouldShowTexthookerTrayEntry({
+2 -2
View File
@@ -61,7 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -92,7 +92,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
},
showFirstRunSetup: deps.showFirstRunSetup(),
openWindowsMpvLauncherSetup: () => {
deps.openFirstRunSetupWindow();
deps.openFirstRunSetupWindow(true);
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
+2 -1
View File
@@ -28,7 +28,8 @@ test('tray main deps builders return mapped handlers', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
+1 -1
View File
@@ -51,7 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
+2 -1
View File
@@ -57,6 +57,7 @@ test('tray menu template contains expected entries and handlers', () => {
false,
);
assert.equal(template[0]!.label, 'Open Help');
assert.equal(template[3]!.label, 'Open SubMiner Setup');
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false);
@@ -102,7 +103,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
assert.equal(labels.includes('Open SubMiner Setup'), false);
assert.equal(labels.includes('Jellyfin Discovery'), false);
});
+1 -1
View File
@@ -89,7 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
...(handlers.showWindowsMpvLauncherSetup
? [
{
label: 'Manage Windows mpv launcher',
label: 'Open SubMiner Setup',
click: handlers.openWindowsMpvLauncherSetup,
},
]
+31 -1
View File
@@ -72,6 +72,7 @@ test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', ()
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
'C:\\a.mkv',
@@ -100,6 +101,7 @@ test('buildWindowsMpvLaunchArgs inserts maximized launch mode before explicit ex
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
'--window-maximized=yes',
@@ -129,6 +131,7 @@ test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () =
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
],
@@ -154,6 +157,7 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
'--input-ipc-server',
@@ -182,6 +186,7 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
'--input-ipc-server',
@@ -223,6 +228,31 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
});
test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => {
const args = buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'normal',
{
socketPath: 'C:\\Users\\tester\\AppData\\Local\\Temp\\subminer-smoke-sock\\subminer.sock',
binaryPath: '',
backend: 'windows',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'F7',
},
);
assert.ok(args.includes('--input-ipc-server=\\\\.\\pipe\\subminer-socket'));
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/);
});
test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
@@ -258,7 +288,7 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
]);
});
+1
View File
@@ -143,6 +143,7 @@ export function buildWindowsMpvLaunchArgs(
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []),
...buildMpvLaunchModeArgs(launchMode),