[codex] Replace mpv fullscreen toggle with launch mode config (#48)

Co-authored-by: bee <autumn@skerritt.blog>
This commit is contained in:
Autumn (Bee)
2026-04-07 16:38:15 +09:00
committed by GitHub
parent 7a64488ed5
commit bc7dde3b02
31 changed files with 305 additions and 31 deletions

View File

@@ -0,0 +1,4 @@
type: changed
area: launcher
- Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode.

View File

@@ -461,10 +461,12 @@
// ========================================== // ==========================================
// MPV Launcher // MPV Launcher
// Optional mpv.exe override for Windows playback entry points. // Optional mpv.exe override for Windows playback entry points.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ========================================== // ==========================================
"mpv": { "mpv": {
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
}, // Optional mpv.exe override for Windows playback entry points. }, // Optional mpv.exe override for Windows playback entry points.
// ========================================== // ==========================================

View File

@@ -461,10 +461,12 @@
// ========================================== // ==========================================
// MPV Launcher // MPV Launcher
// Optional mpv.exe override for Windows playback entry points. // Optional mpv.exe override for Windows playback entry points.
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH. // Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ========================================== // ==========================================
"mpv": { "mpv": {
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
}, // Optional mpv.exe override for Windows playback entry points. }, // Optional mpv.exe override for Windows playback entry points.
// ========================================== // ==========================================

View File

@@ -58,6 +58,7 @@ function createContext(): LauncherCommandContext {
jellyfinServer: '', jellyfinServer: '',
jellyfinUsername: '', jellyfinUsername: '',
jellyfinPassword: '', jellyfinPassword: '',
launchMode: 'normal',
}, },
scriptPath: '/tmp/subminer', scriptPath: '/tmp/subminer',
scriptName: 'subminer', scriptName: 'subminer',

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { parseLauncherMpvConfig } from './config/mpv-config.js';
import { readExternalYomitanProfilePath } from './config.js'; import { readExternalYomitanProfilePath } from './config.js';
import { import {
getPluginConfigCandidates, getPluginConfigCandidates,
@@ -80,6 +81,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
assert.equal('userId' in parsed, false); assert.equal('userId' in parsed, false);
}); });
test('parseLauncherMpvConfig reads launch mode preference', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
launchMode: ' maximized ',
executablePath: 'ignored-here',
},
});
assert.equal(parsed.launchMode, 'maximized');
});
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
launchMode: 'wide',
},
});
assert.equal(parsed.launchMode, undefined);
});
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => { test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(` const parsed = parsePluginRuntimeConfigContent(`
# comment # comment

View File

@@ -2,6 +2,7 @@ import { fail } from './log.js';
import type { import type {
Args, Args,
LauncherJellyfinConfig, LauncherJellyfinConfig,
LauncherMpvConfig,
LauncherYoutubeSubgenConfig, LauncherYoutubeSubgenConfig,
LogLevel, LogLevel,
PluginRuntimeConfig, PluginRuntimeConfig,
@@ -13,6 +14,7 @@ import {
} from './config/args-normalizer.js'; } from './config/args-normalizer.js';
import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js'; import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js';
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { parseLauncherMpvConfig } from './config/mpv-config.js';
import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js'; import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
@@ -44,6 +46,12 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
return parseLauncherJellyfinConfig(root); return parseLauncherJellyfinConfig(root);
} }
export function loadLauncherMpvConfig(): LauncherMpvConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
return parseLauncherMpvConfig(root);
}
export function hasLauncherExternalYomitanProfileConfig(): boolean { export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
} }
@@ -56,9 +64,10 @@ export function parseArgs(
argv: string[], argv: string[],
scriptName: string, scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
launcherMpvConfig: LauncherMpvConfig = {},
): Args { ): Args {
const topLevelCommand = resolveTopLevelCommand(argv); const topLevelCommand = resolveTopLevelCommand(argv);
const parsed = createDefaultArgs(launcherConfig); const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig);
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true; parsed.appPassthrough = true;

View File

@@ -1,7 +1,13 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fail } from '../log.js'; import { fail } from '../log.js';
import type { Args, Backend, LauncherYoutubeSubgenConfig, LogLevel } from '../types.js'; import type {
Args,
Backend,
LauncherMpvConfig,
LauncherYoutubeSubgenConfig,
LogLevel,
} from '../types.js';
import { import {
DEFAULT_JIMAKU_API_BASE_URL, DEFAULT_JIMAKU_API_BASE_URL,
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
@@ -83,7 +89,10 @@ function parseDictionaryTarget(value: string): string {
return resolved; return resolved;
} }
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args { export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {},
): Args {
const configuredSecondaryLangs = uniqueNormalizedLangCodes( const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [], launcherConfig.secondarySubLanguages ?? [],
); );
@@ -148,6 +157,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
jellyfinServer: '', jellyfinServer: '',
jellyfinUsername: '', jellyfinUsername: '',
jellyfinPassword: '', jellyfinPassword: '',
launchMode: mpvConfig.launchMode ?? 'normal',
youtubePrimarySubLangs: primarySubLangs, youtubePrimarySubLangs: primarySubLangs,
youtubeSecondarySubLangs: secondarySubLangs, youtubeSecondarySubLangs: secondarySubLangs,
youtubeAudioLangs, youtubeAudioLangs,

View File

@@ -0,0 +1,12 @@
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
import type { LauncherMpvConfig } from '../types.js';
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
const mpvRaw = root.mpv;
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
const mpv = mpvRaw as Record<string, unknown>;
return {
launchMode: parseMpvLaunchMode(mpv.launchMode),
};
}

View File

@@ -1,6 +1,7 @@
import path from 'node:path'; import path from 'node:path';
import { import {
loadLauncherJellyfinConfig, loadLauncherJellyfinConfig,
loadLauncherMpvConfig,
loadLauncherYoutubeSubgenConfig, loadLauncherYoutubeSubgenConfig,
parseArgs, parseArgs,
readPluginRuntimeConfig, readPluginRuntimeConfig,
@@ -52,7 +53,8 @@ async function main(): Promise<void> {
const scriptPath = process.argv[1] || 'subminer'; const scriptPath = process.argv[1] || 'subminer';
const scriptName = path.basename(scriptPath); const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig(); const launcherConfig = loadLauncherYoutubeSubgenConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig); const launcherMpvConfig = loadLauncherMpvConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel); const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
const appPath = findAppBinary(scriptPath); const appPath = findAppBinary(scriptPath);

View File

@@ -7,6 +7,7 @@ import net from 'node:net';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import type { Args } from './types'; import type { Args } from './types';
import { import {
buildConfiguredMpvDefaultArgs,
buildMpvBackendArgs, buildMpvBackendArgs,
buildMpvEnv, buildMpvEnv,
cleanupPlaybackSession, cleanupPlaybackSession,
@@ -234,6 +235,33 @@ test('buildMpvBackendArgs keeps supported Hyprland and Sway auto backends unchan
}); });
}); });
test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured defaults', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildConfiguredMpvDefaultArgs(makeArgs({ launchMode: 'maximized' }), {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
}),
[
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
'--window-maximized=yes',
],
);
});
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => { test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => { const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs()); launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -401,6 +429,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
jellyfinServer: '', jellyfinServer: '',
jellyfinUsername: '', jellyfinUsername: '',
jellyfinPassword: '', jellyfinPassword: '',
launchMode: 'normal',
...overrides, ...overrides,
}; };
} }

View File

@@ -3,6 +3,7 @@ import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import net from 'node:net'; import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js'; import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
@@ -673,9 +674,7 @@ export async function startMpv(
} }
const mpvArgs: string[] = []; const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`); mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push(...buildMpvBackendArgs(args));
if (targetKind === 'url' && isYoutubeTarget(target)) { if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options'); log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes'); mpvArgs.push('--ytdl=yes');
@@ -703,7 +702,6 @@ export async function startMpv(
if (args.mpvArgs) { if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs)); mpvArgs.push(...parseMpvArgString(args.mpvArgs));
} }
if (preloadedSubtitles?.primaryPath) { if (preloadedSubtitles?.primaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`); mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
} }
@@ -979,6 +977,18 @@ export function buildMpvBackendArgs(
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11']; return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
} }
export function buildConfiguredMpvDefaultArgs(
args: Pick<Args, 'profile' | 'backend' | 'launchMode'>,
baseEnv: NodeJS.ProcessEnv = process.env,
): string[] {
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
return mpvArgs;
}
function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void { function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void {
const normalized = chunk.replace(/\r\n/g, '\n'); const normalized = chunk.replace(/\r\n/g, '\n');
for (const line of normalized.split('\n')) { for (const line of normalized.split('\n')) {
@@ -1209,10 +1219,7 @@ export function launchMpvIdleDetached(
// ignore // ignore
} }
const mpvArgs: string[] = []; const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push(...buildMpvBackendArgs(args));
if (args.mpvArgs) { if (args.mpvArgs) {
mpvArgs.push(...parseMpvArgString(args.mpvArgs)); mpvArgs.push(...parseMpvArgString(args.mpvArgs));
} }

View File

@@ -85,6 +85,12 @@ test('parseArgs maps mpv idle action', () => {
assert.equal(parsed.mpvStatus, false); assert.equal(parsed.mpvStatus, false);
}); });
test('parseArgs applies configured mpv launch mode default', () => {
const parsed = parseArgs([], 'subminer', {}, { launchMode: 'maximized' });
assert.equal(parsed.launchMode, 'maximized');
});
test('parseArgs maps dictionary command and log-level override', () => { test('parseArgs maps dictionary command and log-level override', () => {
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import type { MpvLaunchMode } from '../src/types/config.js';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
@@ -140,6 +141,7 @@ export interface Args {
jellyfinServer: string; jellyfinServer: string;
jellyfinUsername: string; jellyfinUsername: string;
jellyfinPassword: string; jellyfinPassword: string;
launchMode: MpvLaunchMode;
} }
export interface LauncherYoutubeSubgenConfig { export interface LauncherYoutubeSubgenConfig {
@@ -167,6 +169,10 @@ export interface LauncherJellyfinConfig {
iconCacheDir?: string; iconCacheDir?: string;
} }
export interface LauncherMpvConfig {
launchMode?: MpvLaunchMode;
}
export interface PluginRuntimeConfig { export interface PluginRuntimeConfig {
socketPath: string; socketPath: string;
autoStart: boolean; autoStart: boolean;

View File

@@ -93,6 +93,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
}, },
mpv: { mpv: {
executablePath: '', executablePath: '',
launchMode: 'normal',
}, },
anilist: { anilist: {
enabled: false, enabled: false,

View File

@@ -29,6 +29,7 @@ test('config option registry includes critical paths and has unique entries', ()
'anilist.characterDictionary.enabled', 'anilist.characterDictionary.enabled',
'anilist.characterDictionary.collapsibleSections.description', 'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath', 'mpv.executablePath',
'mpv.launchMode',
'yomitan.externalProfilePath', 'yomitan.externalProfilePath',
'immersionTracking.enabled', 'immersionTracking.enabled',
]) { ]) {

View File

@@ -1,4 +1,5 @@
import { ResolvedConfig } from '../../types/config'; import { ResolvedConfig } from '../../types/config';
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared'; import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
export function buildIntegrationConfigOptionRegistry( export function buildIntegrationConfigOptionRegistry(
@@ -245,6 +246,14 @@ export function buildIntegrationConfigOptionRegistry(
description: description:
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.', 'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
}, },
{
path: 'mpv.launchMode',
kind: 'enum',
enumValues: MPV_LAUNCH_MODE_VALUES,
defaultValue: defaultConfig.mpv.launchMode,
description:
'Default window state for SubMiner-managed mpv launches.',
},
{ {
path: 'jellyfin.enabled', path: 'jellyfin.enabled',
kind: 'boolean', kind: 'boolean',

View File

@@ -159,6 +159,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'MPV Launcher', title: 'MPV Launcher',
description: [ description: [
'Optional mpv.exe override for Windows playback entry points.', 'Optional mpv.exe override for Windows playback entry points.',
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.', 'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
], ],
key: 'mpv', key: 'mpv',

View File

@@ -13,6 +13,17 @@ test('resolveConfig trims configured mpv executable path', () => {
assert.deepEqual(warnings, []); assert.deepEqual(warnings, []);
}); });
test('resolveConfig parses configured mpv launch mode', () => {
const { resolved, warnings } = resolveConfig({
mpv: {
launchMode: 'maximized',
},
});
assert.equal(resolved.mpv.launchMode, 'maximized');
assert.deepEqual(warnings, []);
});
test('resolveConfig warns for invalid mpv executable path type', () => { test('resolveConfig warns for invalid mpv executable path type', () => {
const { resolved, warnings } = resolveConfig({ const { resolved, warnings } = resolveConfig({
mpv: { mpv: {
@@ -29,3 +40,20 @@ test('resolveConfig warns for invalid mpv executable path type', () => {
message: 'Expected string.', message: 'Expected string.',
}); });
}); });
test('resolveConfig warns for invalid mpv launch mode', () => {
const { resolved, warnings } = resolveConfig({
mpv: {
launchMode: 'cinema' as never,
},
});
assert.equal(resolved.mpv.launchMode, 'normal');
assert.equal(warnings.length, 1);
assert.deepEqual(warnings[0], {
path: 'mpv.launchMode',
value: 'cinema',
fallback: 'normal',
message: "Expected one of: 'normal', 'maximized', 'fullscreen'.",
});
});

View File

@@ -1,5 +1,6 @@
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode';
import { ResolveContext } from './context'; import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared'; import { asBoolean, asNumber, asString, isObject } from './shared';
@@ -240,6 +241,18 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string.', 'Expected string.',
); );
} }
const launchMode = parseMpvLaunchMode(src.mpv.launchMode);
if (launchMode !== undefined) {
resolved.mpv.launchMode = launchMode;
} else if (src.mpv.launchMode !== undefined) {
warn(
'mpv.launchMode',
src.mpv.launchMode,
resolved.mpv.launchMode,
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
);
}
} else if (src.mpv !== undefined) { } else if (src.mpv !== undefined) {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.'); warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
} }

View File

@@ -20,6 +20,7 @@ import {
import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin'; import { resolvePackagedFirstRunPluginAssets } from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
const DEFAULT_TEXTHOOKER_PORT = 5174; const DEFAULT_TEXTHOOKER_PORT = 5174;
@@ -36,21 +37,6 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
} }
} }
function readConfiguredWindowsMpvExecutablePath(configDir: string): string {
const loadResult = loadRawConfigStrict({
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
});
if (!loadResult.ok) {
return '';
}
return typeof loadResult.config.mpv?.executablePath === 'string'
? loadResult.config.mpv.executablePath.trim()
: '';
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined { function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
const assets = resolvePackagedFirstRunPluginAssets({ const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname, dirname: __dirname,
@@ -64,6 +50,31 @@ function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
return path.join(assets.pluginDirSource, 'main.lua'); return path.join(assets.pluginDirSource, 'main.lua');
} }
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); process.argv = normalizeStartupArgv(process.argv, process.env);
applySanitizedEnv(sanitizeStartupEnv(process.env)); applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app); const userDataPath = configureEarlyAppPaths(app);
@@ -92,6 +103,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env); const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv); applySanitizedEnv(sanitizedEnv);
void app.whenReady().then(async () => { void app.whenReady().then(async () => {
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
const result = await launchWindowsMpv( const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv), normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({ createWindowsMpvLaunchDeps({
@@ -103,7 +115,8 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
normalizeLaunchMpvExtraArgs(process.argv), normalizeLaunchMpvExtraArgs(process.argv),
process.execPath, process.execPath,
resolveBundledWindowsMpvPluginEntrypoint(), resolveBundledWindowsMpvPluginEntrypoint(),
readConfiguredWindowsMpvExecutablePath(userDataPath), configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
); );
app.exit(result.ok ? 0 : 1); app.exit(result.ok ? 0 : 1);
}); });

View File

@@ -1052,6 +1052,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
undefined, undefined,
undefined, undefined,
getResolvedConfig().mpv.executablePath, getResolvedConfig().mpv.executablePath,
getResolvedConfig().mpv.launchMode,
), ),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -2031,6 +2032,7 @@ const {
}, },
launchMpvIdleForJellyfinPlaybackMainDeps: { launchMpvIdleForJellyfinPlaybackMainDeps: {
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
platform: process.platform, platform: process.platform,
execPath: process.execPath, execPath: process.execPath,
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,

View File

@@ -26,6 +26,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
}, },
launchMpvIdleForJellyfinPlaybackMainDeps: { launchMpvIdleForJellyfinPlaybackMainDeps: {
getSocketPath: () => '/tmp/test-mpv.sock', getSocketPath: () => '/tmp/test-mpv.sock',
getLaunchMode: () => 'normal',
platform: 'linux', platform: 'linux',
execPath: process.execPath, execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log', defaultMpvLogPath: '/tmp/test-mpv.log',

View File

@@ -33,6 +33,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
}; };
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({ const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
getSocketPath: () => '/tmp/mpv.sock', getSocketPath: () => '/tmp/mpv.sock',
getLaunchMode: () => 'fullscreen',
platform: 'darwin', platform: 'darwin',
execPath: '/tmp/subminer', execPath: '/tmp/subminer',
defaultMpvLogPath: '/tmp/mpv.log', defaultMpvLogPath: '/tmp/mpv.log',
@@ -47,6 +48,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
})(); })();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock'); assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getLaunchMode(), 'fullscreen');
assert.equal(deps.platform, 'darwin'); assert.equal(deps.platform, 'darwin');
assert.equal(deps.execPath, '/tmp/subminer'); assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');

View File

@@ -17,6 +17,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
) { ) {
return (): LaunchMpvForJellyfinDeps => ({ return (): LaunchMpvForJellyfinDeps => ({
getSocketPath: () => deps.getSocketPath(), getSocketPath: () => deps.getSocketPath(),
getLaunchMode: () => deps.getLaunchMode(),
platform: deps.platform, platform: deps.platform,
execPath: deps.execPath, execPath: deps.execPath,
defaultMpvLogPath: deps.defaultMpvLogPath, defaultMpvLogPath: deps.defaultMpvLogPath,

View File

@@ -31,6 +31,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
const logs: string[] = []; const logs: string[] = [];
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
getSocketPath: () => '/tmp/subminer.sock', getSocketPath: () => '/tmp/subminer.sock',
getLaunchMode: () => 'maximized',
platform: 'darwin', platform: 'darwin',
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
defaultMpvLogPath: '/tmp/mp.log', defaultMpvLogPath: '/tmp/mp.log',
@@ -49,6 +50,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
launch(); launch();
assert.equal(spawnedArgs.length, 1); assert.equal(spawnedArgs.length, 1);
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
assert.ok(spawnedArgs[0]!.includes('--idle=yes')); assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));

View File

@@ -1,3 +1,6 @@
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import type { MpvLaunchMode } from '../../types/config';
type MpvClientLike = { type MpvClientLike = {
connected: boolean; connected: boolean;
connect: () => void; connect: () => void;
@@ -34,6 +37,7 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
export type LaunchMpvForJellyfinDeps = { export type LaunchMpvForJellyfinDeps = {
getSocketPath: () => string; getSocketPath: () => string;
getLaunchMode: () => MpvLaunchMode;
platform: NodeJS.Platform; platform: NodeJS.Platform;
execPath: string; execPath: string;
defaultMpvLogPath: string; defaultMpvLogPath: string;
@@ -58,6 +62,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`; const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
const mpvArgs = [ const mpvArgs = [
...deps.defaultMpvArgs, ...deps.defaultMpvArgs,
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
'--idle=yes', '--idle=yes',
scriptOpts, scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`, `--log-file=${deps.defaultMpvLogPath}`,

View File

@@ -80,6 +80,35 @@ test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', ()
); );
}); });
test('buildWindowsMpvLaunchArgs inserts maximized launch mode before explicit extra args when configured', () => {
assert.deepEqual(
buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
['--window-maximized=no'],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'maximized',
),
[
'--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',
'--window-maximized=yes',
'--window-maximized=no',
'C:\\video.mkv',
],
);
});
test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => { test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => {
assert.deepEqual( assert.deepEqual(
buildWindowsMpvLaunchArgs( buildWindowsMpvLaunchArgs(

View File

@@ -1,5 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import type { MpvLaunchMode } from '../../types/config';
export interface WindowsMpvLaunchDeps { export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined; getEnv: (name: string) => string | undefined;
@@ -89,6 +91,7 @@ export function buildWindowsMpvLaunchArgs(
extraArgs: string[] = [], extraArgs: string[] = [],
binaryPath?: string, binaryPath?: string,
pluginEntrypointPath?: string, pluginEntrypointPath?: string,
launchMode: MpvLaunchMode = 'normal',
): string[] { ): string[] {
const launchIdle = targets.length === 0; const launchIdle = targets.length === 0;
const inputIpcServer = const inputIpcServer =
@@ -119,6 +122,7 @@ export function buildWindowsMpvLaunchArgs(
'--secondary-sid=auto', '--secondary-sid=auto',
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []), ...(scriptOpts ? [scriptOpts] : []),
...buildMpvLaunchModeArgs(launchMode),
...extraArgs, ...extraArgs,
...targets, ...targets,
]; ];
@@ -131,6 +135,7 @@ export async function launchWindowsMpv(
binaryPath?: string, binaryPath?: string,
pluginEntrypointPath?: string, pluginEntrypointPath?: string,
configuredMpvPath?: string, configuredMpvPath?: string,
launchMode: MpvLaunchMode = 'normal',
): Promise<{ ok: boolean; mpvPath: string }> { ): Promise<{ ok: boolean; mpvPath: string }> {
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath); const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath); const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
@@ -147,7 +152,13 @@ export async function launchWindowsMpv(
try { try {
await deps.spawnDetached( await deps.spawnDetached(
mpvPath, mpvPath,
buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath), buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
pluginEntrypointPath,
launchMode,
),
); );
return { ok: true, mpvPath }; return { ok: true, mpvPath };
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMpvLaunchModeArgs, parseMpvLaunchMode } from './mpv-launch-mode';
test('parseMpvLaunchMode normalizes valid string values', () => {
assert.equal(parseMpvLaunchMode(' fullscreen '), 'fullscreen');
assert.equal(parseMpvLaunchMode('MAXIMIZED'), 'maximized');
assert.equal(parseMpvLaunchMode('normal'), 'normal');
});
test('buildMpvLaunchModeArgs returns the expected mpv flags', () => {
assert.deepEqual(buildMpvLaunchModeArgs('normal'), []);
assert.deepEqual(buildMpvLaunchModeArgs('maximized'), ['--window-maximized=yes']);
assert.deepEqual(buildMpvLaunchModeArgs('fullscreen'), ['--fullscreen']);
});

View File

@@ -0,0 +1,24 @@
import type { MpvLaunchMode } from '../types/config';
export const MPV_LAUNCH_MODE_VALUES = ['normal', 'maximized', 'fullscreen'] as const satisfies
readonly MpvLaunchMode[];
export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
return MPV_LAUNCH_MODE_VALUES.find((candidate) => candidate === normalized);
}
export function buildMpvLaunchModeArgs(launchMode: MpvLaunchMode): string[] {
switch (launchMode) {
case 'fullscreen':
return ['--fullscreen'];
case 'maximized':
return ['--window-maximized=yes'];
default:
return [];
}
}

View File

@@ -50,8 +50,11 @@ export interface TexthookerConfig {
openBrowser?: boolean; openBrowser?: boolean;
} }
export type MpvLaunchMode = 'normal' | 'maximized' | 'fullscreen';
export interface MpvConfig { export interface MpvConfig {
executablePath?: string; executablePath?: string;
launchMode?: MpvLaunchMode;
} }
export type SubsyncMode = 'auto' | 'manual'; export type SubsyncMode = 'auto' | 'manual';
@@ -129,6 +132,7 @@ export interface ResolvedConfig {
texthooker: Required<TexthookerConfig>; texthooker: Required<TexthookerConfig>;
mpv: { mpv: {
executablePath: string; executablePath: string;
launchMode: MpvLaunchMode;
}; };
controller: { controller: {
enabled: boolean; enabled: boolean;