feat(launcher): pause auto-start playback until overlay is ready

This commit is contained in:
2026-02-28 22:19:49 -08:00
parent 33007b3f40
commit a46f90d085
20 changed files with 502 additions and 36 deletions

View File

@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {},
processAdapter: adapter,

View File

@@ -1,4 +1,4 @@
import type { Args, LauncherJellyfinConfig } from '../types.js';
import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
import type { ProcessAdapter } from '../process-adapter.js';
export interface LauncherCommandContext {
@@ -6,6 +6,7 @@ export interface LauncherCommandContext {
scriptPath: string;
scriptName: string;
mpvSocketPath: string;
pluginRuntimeConfig: PluginRuntimeConfig;
appPath: string | null;
launcherJellyfinConfig: LauncherJellyfinConfig;
processAdapter: ProcessAdapter;

View File

@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
}
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
@@ -137,6 +137,19 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
log('info', args.logLevel, 'YouTube subtitle mode: off');
}
const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
log(
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
}
startMpv(
selectedTarget.target,
selectedTarget.kind,
@@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
mpvSocketPath,
appPath,
preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
);
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
@@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
if (shouldStartOverlay) {
if (ready) {
@@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
);
}
await startOverlay(appPath, args, mpvSocketPath);
} else if (pluginAutoStartEnabled) {
if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
}
} else if (ready) {
log(
'info',

View File

@@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
assert.equal('userId' in parsed, false);
});
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
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');
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, true);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, false);
});

View File

@@ -16,21 +16,62 @@ export function getPluginConfigCandidates(): string[] {
}
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
const runtimeConfig: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const parseBooleanValue = (value: string, fallback: boolean): boolean => {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
return fallback;
};
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
if (!socketMatch) continue;
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
if (value) runtimeConfig.socketPath = value;
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(value, runtimeConfig.autoStart);
continue;
}
if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
value,
runtimeConfig.autoStartVisibleOverlay,
);
continue;
}
if (key === 'auto_start_pause_until_ready') {
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
value,
runtimeConfig.autoStartPauseUntilReady,
);
}
}
return runtimeConfig;
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const candidates = getPluginConfigCandidates();
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
const defaults: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue;
@@ -39,7 +80,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
logLevel,
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
`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 {
@@ -51,7 +92,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log(
'debug',
logLevel,
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
`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})`,
);
return defaults;
}

View File

@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
scriptPath: string,
mpvSocketPath: string,
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
appPath: string | null,
): LauncherCommandContext {
return {
args,
scriptPath,
scriptName: path.basename(scriptPath),
mpvSocketPath,
mpvSocketPath: pluginRuntimeConfig.socketPath,
pluginRuntimeConfig,
appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter,
@@ -55,7 +56,7 @@ async function main(): Promise<void> {
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
if (runDoctorCommand(context)) {
return;
@@ -71,6 +72,7 @@ async function main(): Promise<void> {
const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
const appContext: LauncherCommandContext = {
...context,
appPath: resolvedAppPath,

View File

@@ -426,6 +426,7 @@ export function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
): void {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -475,6 +476,9 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) {

View File

@@ -319,3 +319,43 @@ test(
});
},
);
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: 20000 },
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'),
);
const env = makeTestEnv(smokeCase);
const result = runLauncher(
smokeCase,
[smokeCase.videoPath, '--log-level', 'debug'],
env,
'autoplay-ready-gate',
);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
const mpvFirstArgs = mpvEntries[0]?.argv;
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
});
},
);

View File

@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
export interface PluginRuntimeConfig {
socketPath: string;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
}
export interface CommandExecOptions {