Fix launcher overlay startup gating and socket alignment

- only start overlay from launcher when --start is passed or plugin auto_start is enabled

- read socket_path from mpv script-opts/subminer.conf and use it for mpv/overlay/subtitle IPC

- only stop overlay on launcher cleanup when launcher actually started it

- preserve --start semantics for second-instance command+action flows so MPV reconnect happens before toggles
This commit is contained in:
2026-02-12 00:45:15 -08:00
parent f722ab2075
commit 9f490a6565
3 changed files with 147 additions and 32 deletions

View File

@@ -255,7 +255,7 @@ test("handleCliCommandService still runs non-start actions on second-instance",
deps, deps,
); );
assert.ok(calls.includes("toggleVisibleOverlay")); assert.ok(calls.includes("toggleVisibleOverlay"));
assert.equal(calls.some((value) => value === "connectMpvClient"), false); assert.equal(calls.some((value) => value === "connectMpvClient"), true);
}); });
test("handleCliCommandService handles visibility and utility command dispatches", () => { test("handleCliCommandService handles visibility and utility command dispatches", () => {

View File

@@ -215,19 +215,18 @@ export function handleCliCommandService(
args.openRuntimeOptions || args.openRuntimeOptions ||
args.texthooker || args.texthooker ||
args.help; args.help;
const ignoreStart = source === "second-instance" && args.start; const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction;
if (ignoreStart && !hasNonStartAction) { if (ignoreStartOnly) {
deps.log("Ignoring --start because SubMiner is already running."); deps.log("Ignoring --start because SubMiner is already running.");
return; return;
} }
const shouldStart = const shouldStart =
!ignoreStart && args.start ||
(args.start || (source === "initial" &&
(source === "initial" && (args.toggle ||
(args.toggle || args.toggleVisibleOverlay ||
args.toggleVisibleOverlay || args.toggleInvisibleOverlay));
args.toggleInvisibleOverlay)));
const needsOverlayRuntime = commandNeedsOverlayRuntime(args); const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
if (args.socketPath !== undefined) { if (args.socketPath !== undefined) {

162
subminer
View File

@@ -65,6 +65,7 @@ interface Args {
directory: string; directory: string;
recursive: boolean; recursive: boolean;
profile: string; profile: string;
startOverlay: boolean;
youtubeSubgenMode: YoutubeSubgenMode; youtubeSubgenMode: YoutubeSubgenMode;
whisperBin: string; whisperBin: string;
whisperModel: string; whisperModel: string;
@@ -91,6 +92,11 @@ interface LauncherYoutubeSubgenConfig {
secondarySubLanguages?: string[]; secondarySubLanguages?: string[];
} }
interface PluginRuntimeConfig {
autoStartOverlay: boolean;
socketPath: string;
}
const COLORS = { const COLORS = {
red: "\x1b[0;31m", red: "\x1b[0;31m",
green: "\x1b[0;32m", green: "\x1b[0;32m",
@@ -111,6 +117,7 @@ const state = {
mpvProc: null as ReturnType<typeof spawn> | null, mpvProc: null as ReturnType<typeof spawn> | null,
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(), youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
appPath: "" as string, appPath: "" as string,
overlayManagedByLauncher: false,
stopRequested: false, stopRequested: false,
}; };
@@ -124,6 +131,7 @@ Options:
-d, --directory DIR Directory to browse for videos (default: current directory) -d, --directory DIR Directory to browse for videos (default: current directory)
-r, --recursive Search for videos recursively -r, --recursive Search for videos recursively
-p, --profile PROFILE MPV profile to use (default: subminer) -p, --profile PROFILE MPV profile to use (default: subminer)
--start Explicitly start SubMiner overlay
--yt-subgen-mode MODE --yt-subgen-mode MODE
YouTube subtitle generation mode: automatic, preprocess, off (default: automatic) YouTube subtitle generation mode: automatic, preprocess, off (default: automatic)
--whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription) --whisper-bin PATH whisper.cpp CLI binary (used for fallback transcription)
@@ -1358,6 +1366,7 @@ function parseArgs(
directory: ".", directory: ".",
recursive: false, recursive: false,
profile: "subminer", profile: "subminer",
startOverlay: false,
youtubeSubgenMode: defaultMode, youtubeSubgenMode: defaultMode,
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || "", whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || "",
whisperModel: whisperModel:
@@ -1425,6 +1434,12 @@ function parseArgs(
continue; continue;
} }
if (arg === "--start") {
parsed.startOverlay = true;
i += 1;
continue;
}
if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") { if (arg === "--yt-subgen-mode" || arg === "--youtube-subgen-mode") {
const value = (argv[i + 1] || "").toLowerCase(); const value = (argv[i + 1] || "").toLowerCase();
if (!isValidYoutubeSubgenMode(value)) { if (!isValidYoutubeSubgenMode(value)) {
@@ -1631,6 +1646,7 @@ function startOverlay(
if (args.useTexthooker) overlayArgs.push("--texthooker"); if (args.useTexthooker) overlayArgs.push("--texthooker");
state.overlayProc = spawn(appPath, overlayArgs, { stdio: "inherit" }); state.overlayProc = spawn(appPath, overlayArgs, { stdio: "inherit" });
state.overlayManagedByLauncher = true;
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, 2000); setTimeout(resolve, 2000);
@@ -1649,23 +1665,25 @@ function launchTexthookerOnly(appPath: string, args: Args): never {
} }
function stopOverlay(args: Args): void { function stopOverlay(args: Args): void {
if (!state.appPath || state.stopRequested) return; if (state.stopRequested) return;
state.stopRequested = true; state.stopRequested = true;
log("info", args.logLevel, "Stopping SubMiner overlay..."); if (state.overlayManagedByLauncher && state.appPath) {
log("info", args.logLevel, "Stopping SubMiner overlay...");
const stopArgs = ["--stop"]; const stopArgs = ["--stop"];
if (args.logLevel === "debug") stopArgs.push("--verbose"); if (args.logLevel === "debug") stopArgs.push("--verbose");
else if (args.logLevel !== "info") else if (args.logLevel !== "info")
stopArgs.push("--log-level", args.logLevel); stopArgs.push("--log-level", args.logLevel);
spawnSync(state.appPath, stopArgs, { stdio: "ignore" }); spawnSync(state.appPath, stopArgs, { stdio: "ignore" });
if (state.overlayProc && !state.overlayProc.killed) { if (state.overlayProc && !state.overlayProc.killed) {
try { try {
state.overlayProc.kill("SIGTERM"); state.overlayProc.kill("SIGTERM");
} catch { } catch {
// ignore // ignore
}
} }
} }
@@ -1689,6 +1707,93 @@ function stopOverlay(args: Args): void {
state.youtubeSubgenChildren.clear(); state.youtubeSubgenChildren.clear();
} }
function parseBoolLike(value: string): boolean | null {
const normalized = value.trim().toLowerCase();
if (
normalized === "yes" ||
normalized === "true" ||
normalized === "1" ||
normalized === "on"
) {
return true;
}
if (
normalized === "no" ||
normalized === "false" ||
normalized === "0" ||
normalized === "off"
) {
return false;
}
return null;
}
function getPluginConfigCandidates(): string[] {
const xdgConfigHome =
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return Array.from(
new Set([
path.join(xdgConfigHome, "mpv", "script-opts", "subminer.conf"),
path.join(os.homedir(), ".config", "mpv", "script-opts", "subminer.conf"),
]),
);
}
function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = {
autoStartOverlay: false,
socketPath: DEFAULT_SOCKET_PATH,
};
const candidates = getPluginConfigCandidates();
for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue;
try {
const content = fs.readFileSync(configPath, "utf8");
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
const autoStartMatch = trimmed.match(/^auto_start\s*=\s*(.+)$/i);
if (autoStartMatch) {
const value = (autoStartMatch[1] || "").split("#", 1)[0]?.trim() || "";
const parsed = parseBoolLike(value);
if (parsed !== null) {
runtimeConfig.autoStartOverlay = parsed;
}
continue;
}
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
if (socketMatch) {
const value = (socketMatch[1] || "").split("#", 1)[0]?.trim() || "";
if (value) runtimeConfig.socketPath = value;
}
}
log(
"debug",
logLevel,
`Using mpv plugin settings from ${configPath}: auto_start=${runtimeConfig.autoStartOverlay ? "yes" : "no"} socket_path=${runtimeConfig.socketPath}`,
);
return runtimeConfig;
} catch {
log(
"warn",
logLevel,
`Failed to read ${configPath}; using launcher defaults`,
);
return runtimeConfig;
}
}
log(
"debug",
logLevel,
`No mpv subminer.conf found; using launcher defaults (auto_start=no socket_path=${runtimeConfig.socketPath})`,
);
return runtimeConfig;
}
function waitForSocket( function waitForSocket(
socketPath: string, socketPath: string,
timeoutMs = 10000, timeoutMs = 10000,
@@ -1827,6 +1932,8 @@ async function main(): Promise<void> {
const scriptName = path.basename(process.argv[1] || "subminer"); const scriptName = path.basename(process.argv[1] || "subminer");
const launcherConfig = loadLauncherYoutubeSubgenConfig(); const launcherConfig = loadLauncherYoutubeSubgenConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig); const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
const mpvSocketPath = pluginRuntimeConfig.socketPath;
log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`); log("debug", args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
@@ -1893,7 +2000,7 @@ async function main(): Promise<void> {
targetChoice.target, targetChoice.target,
targetChoice.kind, targetChoice.kind,
args, args,
DEFAULT_SOCKET_PATH, mpvSocketPath,
preloadedSubtitles, preloadedSubtitles,
); );
@@ -1901,7 +2008,7 @@ async function main(): Promise<void> {
void generateYoutubeSubtitles(targetChoice.target, args, async (lang, subtitlePath) => { void generateYoutubeSubtitles(targetChoice.target, args, async (lang, subtitlePath) => {
try { try {
await loadSubtitleIntoMpv( await loadSubtitleIntoMpv(
DEFAULT_SOCKET_PATH, mpvSocketPath,
subtitlePath, subtitlePath,
lang === "primary", lang === "primary",
args.logLevel, args.logLevel,
@@ -1922,21 +2029,30 @@ async function main(): Promise<void> {
}); });
} }
const ready = await waitForSocket(DEFAULT_SOCKET_PATH); const shouldStartOverlay = args.startOverlay || pluginRuntimeConfig.autoStartOverlay;
if (ready) { if (shouldStartOverlay) {
log( const ready = await waitForSocket(mpvSocketPath);
"info", if (ready) {
args.logLevel, log(
"MPV IPC socket ready, starting SubMiner overlay", "info",
); args.logLevel,
"MPV IPC socket ready, starting SubMiner overlay",
);
} else {
log(
"info",
args.logLevel,
"MPV IPC socket not ready after timeout, starting SubMiner overlay anyway",
);
}
await startOverlay(appPath, args, mpvSocketPath);
} else { } else {
log( log(
"info", "info",
args.logLevel, args.logLevel,
"MPV IPC socket not ready after timeout, starting SubMiner overlay anyway", "SubMiner overlay auto-start disabled; not launching overlay process",
); );
} }
await startOverlay(appPath, args, DEFAULT_SOCKET_PATH);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (!state.mpvProc) { if (!state.mpvProc) {