From bc7dde3b0271416621919b60c20b1b193d54b30c Mon Sep 17 00:00:00 2001 From: "Autumn (Bee)" Date: Tue, 7 Apr 2026 16:38:15 +0900 Subject: [PATCH] [codex] Replace mpv fullscreen toggle with launch mode config (#48) Co-authored-by: bee --- changes/2026.04.06-mpv-launch-mode.md | 4 ++ config.example.jsonc | 4 +- docs-site/public/config.example.jsonc | 4 +- launcher/commands/playback-command.test.ts | 1 + launcher/config-domain-parsers.test.ts | 22 +++++++++ launcher/config.ts | 11 ++++- launcher/config/args-normalizer.ts | 14 +++++- launcher/config/mpv-config.ts | 12 +++++ launcher/main.ts | 4 +- launcher/mpv.test.ts | 29 ++++++++++++ launcher/mpv.ts | 23 ++++++---- launcher/parse-args.test.ts | 6 +++ launcher/types.ts | 6 +++ .../definitions/defaults-integrations.ts | 1 + .../definitions/domain-registry.test.ts | 1 + .../definitions/options-integrations.ts | 9 ++++ src/config/definitions/template-sections.ts | 1 + src/config/resolve/integrations.test.ts | 28 ++++++++++++ src/config/resolve/integrations.ts | 13 ++++++ src/main-entry.ts | 45 ++++++++++++------- src/main.ts | 2 + .../jellyfin-runtime-composer.test.ts | 1 + ...llyfin-remote-connection-main-deps.test.ts | 2 + .../jellyfin-remote-connection-main-deps.ts | 1 + .../jellyfin-remote-connection.test.ts | 2 + .../runtime/jellyfin-remote-connection.ts | 5 +++ src/main/runtime/windows-mpv-launch.test.ts | 29 ++++++++++++ src/main/runtime/windows-mpv-launch.ts | 13 +++++- src/shared/mpv-launch-mode.test.ts | 15 +++++++ src/shared/mpv-launch-mode.ts | 24 ++++++++++ src/types/config.ts | 4 ++ 31 files changed, 305 insertions(+), 31 deletions(-) create mode 100644 changes/2026.04.06-mpv-launch-mode.md create mode 100644 launcher/config/mpv-config.ts create mode 100644 src/shared/mpv-launch-mode.test.ts create mode 100644 src/shared/mpv-launch-mode.ts diff --git a/changes/2026.04.06-mpv-launch-mode.md b/changes/2026.04.06-mpv-launch-mode.md new file mode 100644 index 00000000..2eaee715 --- /dev/null +++ b/changes/2026.04.06-mpv-launch-mode.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index e86fb86d..efd0fd7d 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -461,10 +461,12 @@ // ========================================== // MPV Launcher // 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. // ========================================== "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. // ========================================== diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index e86fb86d..efd0fd7d 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -461,10 +461,12 @@ // ========================================== // MPV Launcher // 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. // ========================================== "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. // ========================================== diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index adb68c47..e3359023 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -58,6 +58,7 @@ function createContext(): LauncherCommandContext { jellyfinServer: '', jellyfinUsername: '', jellyfinPassword: '', + launchMode: 'normal', }, scriptPath: '/tmp/subminer', scriptName: 'subminer', diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index cc3ddfaa..c222102c 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; +import { parseLauncherMpvConfig } from './config/mpv-config.js'; import { readExternalYomitanProfilePath } from './config.js'; import { getPluginConfigCandidates, @@ -80,6 +81,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => 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', () => { const parsed = parsePluginRuntimeConfigContent(` # comment diff --git a/launcher/config.ts b/launcher/config.ts index ad0c2e42..282ed69a 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -2,6 +2,7 @@ import { fail } from './log.js'; import type { Args, LauncherJellyfinConfig, + LauncherMpvConfig, LauncherYoutubeSubgenConfig, LogLevel, PluginRuntimeConfig, @@ -13,6 +14,7 @@ import { } from './config/args-normalizer.js'; import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.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 { readLauncherMainConfigObject } from './config/shared-config-reader.js'; import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; @@ -44,6 +46,12 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { return parseLauncherJellyfinConfig(root); } +export function loadLauncherMpvConfig(): LauncherMpvConfig { + const root = readLauncherMainConfigObject(); + if (!root) return {}; + return parseLauncherMpvConfig(root); +} + export function hasLauncherExternalYomitanProfileConfig(): boolean { return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; } @@ -56,9 +64,10 @@ export function parseArgs( argv: string[], scriptName: string, launcherConfig: LauncherYoutubeSubgenConfig, + launcherMpvConfig: LauncherMpvConfig = {}, ): Args { const topLevelCommand = resolveTopLevelCommand(argv); - const parsed = createDefaultArgs(launcherConfig); + const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig); if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { parsed.appPassthrough = true; diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 0c5627cb..23e36eeb 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -1,7 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; 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 { DEFAULT_JIMAKU_API_BASE_URL, DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, @@ -83,7 +89,10 @@ function parseDictionaryTarget(value: string): string { return resolved; } -export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args { +export function createDefaultArgs( + launcherConfig: LauncherYoutubeSubgenConfig, + mpvConfig: LauncherMpvConfig = {}, +): Args { const configuredSecondaryLangs = uniqueNormalizedLangCodes( launcherConfig.secondarySubLanguages ?? [], ); @@ -148,6 +157,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): jellyfinServer: '', jellyfinUsername: '', jellyfinPassword: '', + launchMode: mpvConfig.launchMode ?? 'normal', youtubePrimarySubLangs: primarySubLangs, youtubeSecondarySubLangs: secondarySubLangs, youtubeAudioLangs, diff --git a/launcher/config/mpv-config.ts b/launcher/config/mpv-config.ts new file mode 100644 index 00000000..2bcbcdb1 --- /dev/null +++ b/launcher/config/mpv-config.ts @@ -0,0 +1,12 @@ +import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js'; +import type { LauncherMpvConfig } from '../types.js'; + +export function parseLauncherMpvConfig(root: Record): LauncherMpvConfig { + const mpvRaw = root.mpv; + if (!mpvRaw || typeof mpvRaw !== 'object') return {}; + const mpv = mpvRaw as Record; + + return { + launchMode: parseMpvLaunchMode(mpv.launchMode), + }; +} diff --git a/launcher/main.ts b/launcher/main.ts index 7c15f078..8f0943c8 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { loadLauncherJellyfinConfig, + loadLauncherMpvConfig, loadLauncherYoutubeSubgenConfig, parseArgs, readPluginRuntimeConfig, @@ -52,7 +53,8 @@ async function main(): Promise { const scriptPath = process.argv[1] || 'subminer'; const scriptName = path.basename(scriptPath); 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 appPath = findAppBinary(scriptPath); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 24855f8f..a618b3f6 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -7,6 +7,7 @@ import net from 'node:net'; import { EventEmitter } from 'node:events'; import type { Args } from './types'; import { + buildConfiguredMpvDefaultArgs, buildMpvBackendArgs, buildMpvEnv, 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', () => { const error = withProcessExitIntercept(() => { launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs()); @@ -401,6 +429,7 @@ function makeArgs(overrides: Partial = {}): Args { jellyfinServer: '', jellyfinUsername: '', jellyfinPassword: '', + launchMode: 'normal', ...overrides, }; } diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 7cb72dbf..9900de53 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import net from 'node:net'; 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 { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; @@ -673,9 +674,7 @@ export async function startMpv( } const mpvArgs: string[] = []; - if (args.profile) mpvArgs.push(`--profile=${args.profile}`); - mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); - mpvArgs.push(...buildMpvBackendArgs(args)); + mpvArgs.push(...buildConfiguredMpvDefaultArgs(args)); if (targetKind === 'url' && isYoutubeTarget(target)) { log('info', args.logLevel, 'Applying URL playback options'); mpvArgs.push('--ytdl=yes'); @@ -703,7 +702,6 @@ export async function startMpv( if (args.mpvArgs) { mpvArgs.push(...parseMpvArgString(args.mpvArgs)); } - if (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']; } +export function buildConfiguredMpvDefaultArgs( + args: Pick, + 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 { const normalized = chunk.replace(/\r\n/g, '\n'); for (const line of normalized.split('\n')) { @@ -1209,10 +1219,7 @@ export function launchMpvIdleDetached( // ignore } - const mpvArgs: string[] = []; - if (args.profile) mpvArgs.push(`--profile=${args.profile}`); - mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS); - mpvArgs.push(...buildMpvBackendArgs(args)); + const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args); if (args.mpvArgs) { mpvArgs.push(...parseMpvArgString(args.mpvArgs)); } diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 907c7d0e..ca133949 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -85,6 +85,12 @@ test('parseArgs maps mpv idle action', () => { 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', () => { const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {}); diff --git a/launcher/types.ts b/launcher/types.ts index d67a1358..64edbe27 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import os from 'node:os'; +import type { MpvLaunchMode } from '../src/types/config.js'; import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; @@ -140,6 +141,7 @@ export interface Args { jellyfinServer: string; jellyfinUsername: string; jellyfinPassword: string; + launchMode: MpvLaunchMode; } export interface LauncherYoutubeSubgenConfig { @@ -167,6 +169,10 @@ export interface LauncherJellyfinConfig { iconCacheDir?: string; } +export interface LauncherMpvConfig { + launchMode?: MpvLaunchMode; +} + export interface PluginRuntimeConfig { socketPath: string; autoStart: boolean; diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index f1a6bcb4..9972c92e 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -93,6 +93,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, mpv: { executablePath: '', + launchMode: 'normal', }, anilist: { enabled: false, diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index c8b3cadf..8c85b57e 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -29,6 +29,7 @@ test('config option registry includes critical paths and has unique entries', () 'anilist.characterDictionary.enabled', 'anilist.characterDictionary.collapsibleSections.description', 'mpv.executablePath', + 'mpv.launchMode', 'yomitan.externalProfilePath', 'immersionTracking.enabled', ]) { diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 61550613..bab1934e 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -1,4 +1,5 @@ import { ResolvedConfig } from '../../types/config'; +import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode'; import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared'; export function buildIntegrationConfigOptionRegistry( @@ -245,6 +246,14 @@ export function buildIntegrationConfigOptionRegistry( description: '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', kind: 'boolean', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 4132c0e8..f3f21098 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -159,6 +159,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'MPV Launcher', description: [ '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.', ], key: 'mpv', diff --git a/src/config/resolve/integrations.test.ts b/src/config/resolve/integrations.test.ts index 4067ab5c..377e4e78 100644 --- a/src/config/resolve/integrations.test.ts +++ b/src/config/resolve/integrations.test.ts @@ -13,6 +13,17 @@ test('resolveConfig trims configured mpv executable path', () => { 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', () => { const { resolved, warnings } = resolveConfig({ mpv: { @@ -29,3 +40,20 @@ test('resolveConfig warns for invalid mpv executable path type', () => { 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'.", + }); +}); diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 68f3380d..bf029cc2 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -1,5 +1,6 @@ import * as os from 'node:os'; import * as path from 'node:path'; +import { MPV_LAUNCH_MODE_VALUES, parseMpvLaunchMode } from '../../shared/mpv-launch-mode'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; @@ -240,6 +241,18 @@ export function applyIntegrationConfig(context: ResolveContext): void { '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) { warn('mpv', src.mpv, resolved.mpv, 'Expected object.'); } diff --git a/src/main-entry.ts b/src/main-entry.ts index 98a279da..b253e798 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -20,6 +20,7 @@ import { import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { resolvePackagedFirstRunPluginAssets } 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'; 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 { const assets = resolvePackagedFirstRunPluginAssets({ dirname: __dirname, @@ -64,6 +50,31 @@ function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined { 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); applySanitizedEnv(sanitizeStartupEnv(process.env)); const userDataPath = configureEarlyAppPaths(app); @@ -92,6 +103,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { const sanitizedEnv = sanitizeLaunchMpvEnv(process.env); applySanitizedEnv(sanitizedEnv); void app.whenReady().then(async () => { + const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath); const result = await launchWindowsMpv( normalizeLaunchMpvTargets(process.argv), createWindowsMpvLaunchDeps({ @@ -103,7 +115,8 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { normalizeLaunchMpvExtraArgs(process.argv), process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), - readConfiguredWindowsMpvExecutablePath(userDataPath), + configuredMpvLaunch.executablePath, + configuredMpvLaunch.launchMode, ); app.exit(result.ok ? 0 : 1); }); diff --git a/src/main.ts b/src/main.ts index f8f0c8d4..78827c51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1052,6 +1052,7 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ undefined, undefined, getResolvedConfig().mpv.executablePath, + getResolvedConfig().mpv.launchMode, ), waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), @@ -2031,6 +2032,7 @@ const { }, launchMpvIdleForJellyfinPlaybackMainDeps: { getSocketPath: () => appState.mpvSocketPath, + getLaunchMode: () => getResolvedConfig().mpv.launchMode, platform: process.platform, execPath: process.execPath, defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index 53b74860..a1fe9810 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -26,6 +26,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' }, launchMpvIdleForJellyfinPlaybackMainDeps: { getSocketPath: () => '/tmp/test-mpv.sock', + getLaunchMode: () => 'normal', platform: 'linux', execPath: process.execPath, defaultMpvLogPath: '/tmp/test-mpv.log', diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts index 79de86a6..2f80e93d 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -33,6 +33,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { }; const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({ getSocketPath: () => '/tmp/mpv.sock', + getLaunchMode: () => 'fullscreen', platform: 'darwin', execPath: '/tmp/subminer', 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.getLaunchMode(), 'fullscreen'); assert.equal(deps.platform, 'darwin'); assert.equal(deps.execPath, '/tmp/subminer'); assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index 563ecc1e..b8257c64 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -17,6 +17,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( ) { return (): LaunchMpvForJellyfinDeps => ({ getSocketPath: () => deps.getSocketPath(), + getLaunchMode: () => deps.getLaunchMode(), platform: deps.platform, execPath: deps.execPath, defaultMpvLogPath: deps.defaultMpvLogPath, diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index f51cf6cc..7b97477a 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -31,6 +31,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( const logs: string[] = []; const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ getSocketPath: () => '/tmp/subminer.sock', + getLaunchMode: () => 'maximized', platform: 'darwin', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', defaultMpvLogPath: '/tmp/mp.log', @@ -49,6 +50,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( launch(); assert.equal(spawnedArgs.length, 1); + assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes')); assert.ok(spawnedArgs[0]!.includes('--idle=yes')); 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'))); diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index 8f34f1ea..eb41370a 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -1,3 +1,6 @@ +import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; +import type { MpvLaunchMode } from '../../types/config'; + type MpvClientLike = { connected: boolean; connect: () => void; @@ -34,6 +37,7 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps) export type LaunchMpvForJellyfinDeps = { getSocketPath: () => string; + getLaunchMode: () => MpvLaunchMode; platform: NodeJS.Platform; execPath: 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 mpvArgs = [ ...deps.defaultMpvArgs, + ...buildMpvLaunchModeArgs(deps.getLaunchMode()), '--idle=yes', scriptOpts, `--log-file=${deps.defaultMpvLogPath}`, diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 44f56bf1..d322affd 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -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', () => { assert.deepEqual( buildWindowsMpvLaunchArgs( diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index d50a6ab2..3d6dfb64 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -1,5 +1,7 @@ import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; +import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; +import type { MpvLaunchMode } from '../../types/config'; export interface WindowsMpvLaunchDeps { getEnv: (name: string) => string | undefined; @@ -89,6 +91,7 @@ export function buildWindowsMpvLaunchArgs( extraArgs: string[] = [], binaryPath?: string, pluginEntrypointPath?: string, + launchMode: MpvLaunchMode = 'normal', ): string[] { const launchIdle = targets.length === 0; const inputIpcServer = @@ -119,6 +122,7 @@ export function buildWindowsMpvLaunchArgs( '--secondary-sid=auto', '--secondary-sub-visibility=no', ...(scriptOpts ? [scriptOpts] : []), + ...buildMpvLaunchModeArgs(launchMode), ...extraArgs, ...targets, ]; @@ -131,6 +135,7 @@ export async function launchWindowsMpv( binaryPath?: string, pluginEntrypointPath?: string, configuredMpvPath?: string, + launchMode: MpvLaunchMode = 'normal', ): Promise<{ ok: boolean; mpvPath: string }> { const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath); const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath); @@ -147,7 +152,13 @@ export async function launchWindowsMpv( try { await deps.spawnDetached( mpvPath, - buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath), + buildWindowsMpvLaunchArgs( + targets, + extraArgs, + binaryPath, + pluginEntrypointPath, + launchMode, + ), ); return { ok: true, mpvPath }; } catch (error) { diff --git a/src/shared/mpv-launch-mode.test.ts b/src/shared/mpv-launch-mode.test.ts new file mode 100644 index 00000000..5659b3e9 --- /dev/null +++ b/src/shared/mpv-launch-mode.test.ts @@ -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']); +}); diff --git a/src/shared/mpv-launch-mode.ts b/src/shared/mpv-launch-mode.ts new file mode 100644 index 00000000..a73fbb6f --- /dev/null +++ b/src/shared/mpv-launch-mode.ts @@ -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 []; + } +} diff --git a/src/types/config.ts b/src/types/config.ts index a775a87f..e6cc4208 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -50,8 +50,11 @@ export interface TexthookerConfig { openBrowser?: boolean; } +export type MpvLaunchMode = 'normal' | 'maximized' | 'fullscreen'; + export interface MpvConfig { executablePath?: string; + launchMode?: MpvLaunchMode; } export type SubsyncMode = 'auto' | 'manual'; @@ -129,6 +132,7 @@ export interface ResolvedConfig { texthooker: Required; mpv: { executablePath: string; + launchMode: MpvLaunchMode; }; controller: { enabled: boolean;