diff --git a/README.md b/README.md index 354ca27e..236f45af 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Look up words with Yomitan, export to Anki in one key, track your immersion — SubMiner runs as an invisible Electron overlay on top of mpv. Subtitles render as an interactive layer. Move your cursor over any word and trigger a [Yomitan](https://github.com/yomidevs/yomitan) lookup. Press one key to snapshot the sentence, audio, and screenshot into Anki via AnkiConnect. +On Windows, the recommended playback entry point is the optional `SubMiner mpv` shortcut created during setup. It launches `mpv` with SubMiner's defaults directly, so you do not need an `mpv.conf` profile just to use the shortcut. + ## Features ### Dictionary Lookups diff --git a/changes/269-windows-mpv-shortcut-idle-overlay.md b/changes/269-windows-mpv-shortcut-idle-overlay.md new file mode 100644 index 00000000..ce4d2b54 --- /dev/null +++ b/changes/269-windows-mpv-shortcut-idle-overlay.md @@ -0,0 +1,5 @@ +type: fixed +area: launcher + +- Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file. +- Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path. diff --git a/docs-site/installation.md b/docs-site/installation.md index 0c46d414..5d2cb160 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -171,8 +171,8 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re ### Windows Usage Notes -- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts. -- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly; they do not require an `mpv.conf` profile named `subminer`. +- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, offer mpv plugin installation, open bundled Yomitan settings, and optionally create `SubMiner mpv` Start Menu/Desktop shortcuts. On Windows, that shortcut is the recommended way to launch mpv playback with SubMiner defaults. +- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly, including the Windows-safe subtitle search paths that skip the extra current-directory scan; they do not require an `mpv.conf` profile named `subminer`. - First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location. - Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows. - Native window tracking is built in on Windows; no `xdotool`, `xwininfo`, or compositor-specific helper is required. diff --git a/docs-site/usage.md b/docs-site/usage.md index 397b03d4..71efecfa 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -117,7 +117,7 @@ SubMiner.AppImage --help # Show all options ### Windows mpv Shortcut -If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. It runs `SubMiner.exe --launch-mpv`, which starts `mpv.exe` with SubMiner's default launch args directly. +If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly. You can use it three ways: @@ -189,7 +189,7 @@ Top-level launcher flags like `--jellyfin-*` are intentionally rejected. You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`. -You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p ...`): +You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. The Windows `SubMiner.exe --launch-mpv` shortcut path uses equivalent args directly, but skips the extra current-directory subtitle scan to avoid duplicate sidecar detection when you drag a video onto the shortcut; the optional profile remains useful for manual mpv launches and the `subminer` wrapper defaults to `--profile=subminer` (or override with `subminer -p ...`): ```ini [subminer] diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index c94e2d59..eac8dc08 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -29,13 +29,25 @@ function M.create(ctx) return options_helper.coerce_bool(raw_auto_start, false) end + local function rearm_managed_subtitle_defaults() + if not process.has_matching_mpv_ipc_socket(opts.socket_path) then + return false + end + + mp.set_property_native("sub-auto", "fuzzy") + mp.set_property_native("sid", "auto") + mp.set_property_native("secondary-sid", "auto") + return true + end + local function on_file_loaded() aniskip.clear_aniskip_state() process.disarm_auto_play_ready_gate() + local has_matching_socket = rearm_managed_subtitle_defaults() local should_auto_start = resolve_auto_start_enabled() if should_auto_start then - if not process.has_matching_mpv_ipc_socket(opts.socket_path) then + if not has_matching_socket then subminer_log( "info", "lifecycle", diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index dc4489fb..7f64814a 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -178,6 +178,12 @@ local function run_plugin_scenario(config) value = value, } end + function mp.set_property(name, value) + recorded.property_sets[#recorded.property_sets + 1] = { + name = name, + value = value, + } + end function mp.get_script_name() return "subminer" end @@ -531,6 +537,38 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + media_title = "Random Movie", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + assert_true( + has_property_set(recorded.property_sets, "sub-auto", "fuzzy"), + "managed file-loaded should rearm sub-auto for idle mpv sessions" + ) + assert_true( + has_property_set(recorded.property_sets, "sid", "auto"), + "managed file-loaded should rearm primary subtitle selection for idle mpv sessions" + ) + assert_true( + has_property_set(recorded.property_sets, "secondary-sid", "auto"), + "managed file-loaded should rearm secondary subtitle selection for idle mpv sessions" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", @@ -1037,6 +1075,10 @@ do start_call == nil, "auto-start should be skipped when mpv input-ipc-server does not match configured socket_path" ) + assert_true( + not has_property_set(recorded.property_sets, "sid", "auto"), + "subtitle rearm should not run when mpv input-ipc-server does not match configured socket_path" + ) assert_true( not has_property_set(recorded.property_sets, "pause", true), "pause-until-ready gate should not arm when socket_path does not match" diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 4a321ec1..9259b70c 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -184,6 +184,39 @@ test('dispatchMpvProtocolMessage sets secondary subtitle track based on track li assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 2] }]); }); +test('dispatchMpvProtocolMessage prefers the already selected matching secondary track', async () => { + const { deps, state } = createDeps(); + + await dispatchMpvProtocolMessage( + { + request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, + data: [ + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'ja.srt', + selected: false, + external: true, + 'external-filename': '/tmp/dupe.srt', + }, + { + type: 'sub', + id: 3, + lang: 'ja', + title: 'ja.srt', + selected: true, + external: true, + 'external-filename': '/tmp/dupe.srt', + }, + ], + }, + deps, + ); + + assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]); +}); + test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => { const { deps, state } = createDeps(); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 03768c3a..13680aa6 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -93,6 +93,97 @@ export interface MpvProtocolHandleMessageDeps { restorePreviousSecondarySubVisibility: () => void; } +type SubtitleTrackCandidate = { + id: number; + lang: string; + title: string; + selected: boolean; + external: boolean; + externalFilename: string | null; +}; + +function normalizeSubtitleTrackCandidate(track: Record): SubtitleTrackCandidate | null { + const id = + typeof track.id === 'number' + ? track.id + : typeof track.id === 'string' + ? Number(track.id.trim()) + : Number.NaN; + if (!Number.isInteger(id)) { + return null; + } + + const externalFilename = + typeof track['external-filename'] === 'string' && track['external-filename'].trim().length > 0 + ? track['external-filename'].trim() + : typeof track.external_filename === 'string' && track.external_filename.trim().length > 0 + ? track.external_filename.trim() + : null; + + return { + id, + lang: String(track.lang || '').trim().toLowerCase(), + title: String(track.title || '').trim().toLowerCase(), + selected: track.selected === true, + external: track.external === true, + externalFilename, + }; +} + +function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string { + if (track.externalFilename) { + return `external:${track.externalFilename.toLowerCase()}`; + } + if (track.title.length > 0) { + return `title:${track.title}`; + } + return `id:${track.id}`; +} + +function pickSecondarySubtitleTrackId( + tracks: Array>, + preferredLanguages: string[], +): number | null { + const normalizedLanguages = preferredLanguages + .map((language) => language.trim().toLowerCase()) + .filter((language) => language.length > 0); + if (normalizedLanguages.length === 0) { + return null; + } + + const subtitleTracks = tracks + .filter((track) => track.type === 'sub') + .map(normalizeSubtitleTrackCandidate) + .filter((track): track is SubtitleTrackCandidate => track !== null); + + const dedupedTracks = new Map(); + for (const track of subtitleTracks) { + const identity = getSubtitleTrackIdentity(track); + const existing = dedupedTracks.get(identity); + if (!existing || (track.selected && !existing.selected)) { + dedupedTracks.set(identity, track); + } + } + + const uniqueTracks = [...dedupedTracks.values()]; + + for (const language of normalizedLanguages) { + const selectedMatch = uniqueTracks.find( + (track) => track.selected && track.lang === language, + ); + if (selectedMatch) { + return selectedMatch.id; + } + + const match = uniqueTracks.find((track) => track.lang === language); + if (match) { + return match.id; + } + } + + return null; +} + export function splitMpvMessagesFromBuffer( buffer: string, onMessage?: MpvMessageParser, @@ -283,15 +374,11 @@ export async function dispatchMpvProtocolMessage( if (Array.isArray(tracks)) { const config = deps.getResolvedConfig(); const languages = config.secondarySub?.secondarySubLanguages || []; - const subTracks = tracks.filter((track) => track.type === 'sub'); - for (const language of languages) { - const match = subTracks.find((track) => track.lang === language); - if (match) { - deps.sendCommand({ - command: ['set_property', 'secondary-sid', match.id], - }); - break; - } + const secondaryTrackId = pickSecondarySubtitleTrackId(tracks, languages); + if (secondaryTrackId !== null) { + deps.sendCommand({ + command: ['set_property', 'secondary-sid', secondaryTrackId], + }); } } } else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) { diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 3eaa41db..adc603d1 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -92,6 +92,52 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => assert.equal(inProgressState, false); }); +test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => { + let payloadTrackCount = 0; + + await triggerSubsyncFromConfig( + makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return '/tmp/video.mkv'; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return 2; + if (name === 'track-list') { + return [ + { id: 1, type: 'sub', selected: true, lang: 'jpn' }, + { + id: 2, + type: 'sub', + selected: true, + external: true, + lang: 'eng', + 'external-filename': '/tmp/ref.srt', + }, + { + id: 3, + type: 'sub', + selected: false, + external: true, + lang: 'eng', + 'external-filename': '/tmp/ref.srt', + }, + ]; + } + return null; + }, + }), + openManualPicker: (payload) => { + payloadTrackCount = payload.sourceTracks.length; + }, + }), + ); + + assert.equal(payloadTrackCount, 1); +}); + test('triggerSubsyncFromConfig reports failures to OSD', async () => { const osd: string[] = []; await triggerSubsyncFromConfig( diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 44dd0907..4d525ea1 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -76,6 +76,31 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] { }); } +function getSourceTrackIdentity(track: MpvTrack): string { + if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) { + return `external:${track['external-filename'].toLowerCase()}`; + } + if (typeof track.id === 'number') { + return `id:${track.id}`; + } + if (typeof track.title === 'string' && track.title.length > 0) { + return `title:${track.title.toLowerCase()}`; + } + return 'unknown'; +} + +function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] { + const deduped = new Map(); + for (const track of tracks) { + const identity = getSourceTrackIdentity(track); + const existing = deduped.get(identity); + if (!existing || (track.selected && !existing.selected)) { + deduped.set(identity, track); + } + } + return [...deduped.values()]; +} + export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps { isSubsyncInProgress: () => boolean; setSubsyncInProgress: (inProgress: boolean) => void; @@ -123,12 +148,13 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise 0; }); + const uniqueSourceTracks = dedupeSourceTracks(sourceTracks); return { videoPath, primaryTrack, secondaryTrack, - sourceTracks, + sourceTracks: uniqueSourceTracks, audioStreamIndex: client.currentAudioStreamIndex, }; } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 9a8bd171..df535e45 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { configureEarlyAppPaths, + normalizeLaunchMpvExtraArgs, normalizeStartupArgv, normalizeLaunchMpvTargets, sanitizeHelpEnv, @@ -70,6 +71,41 @@ test('launch-mpv entry helpers detect and normalize targets', () => { assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [ 'C:\\a.mkv', ]); + assert.deepEqual( + normalizeLaunchMpvExtraArgs([ + 'SubMiner.exe', + '--launch-mpv', + '--profile=subminer', + '--pause=yes', + 'C:\\a.mkv', + ]), + ['--profile=subminer', '--pause=yes'], + ); + assert.deepEqual( + normalizeLaunchMpvExtraArgs([ + 'SubMiner.exe', + '--launch-mpv', + '--input-ipc-server', + '\\\\.\\pipe\\custom-subminer-socket', + '--alang', + 'ja,jpn', + 'C:\\a.mkv', + ]), + ['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'], + ); + assert.deepEqual( + normalizeLaunchMpvTargets([ + 'SubMiner.exe', + '--launch-mpv', + '--input-ipc-server', + '\\\\.\\pipe\\custom-subminer-socket', + '--alang', + 'ja,jpn', + 'C:\\a.mkv', + 'C:\\b.mkv', + ]), + ['C:\\a.mkv', 'C:\\b.mkv'], + ); }); test('stats-daemon entry helper detects internal daemon commands', () => { diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index b6405fae..a8695e3b 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -121,7 +121,94 @@ export function shouldHandleStatsDaemonCommandAtEntry( } export function normalizeLaunchMpvTargets(argv: string[]): string[] { - return parseCliArgs(argv).launchMpvTargets; + const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv'); + if (launchMpvIndex < 0) { + return []; + } + + const targets: string[] = []; + const flagValueArgs = new Set([ + '--alang', + '--input-ipc-server', + '--log-file', + '--profile', + '--script', + '--script-opts', + '--scripts', + '--slang', + '--sub-file-paths', + '--ytdl-format', + ]); + + let parsingTargets = false; + for (let i = launchMpvIndex + 1; i < argv.length; i += 1) { + const token = argv[i]; + if (!token) continue; + + if (parsingTargets) { + targets.push(token); + continue; + } + + if (token === '--') { + parsingTargets = true; + continue; + } + + if (token.startsWith('-')) { + if (!token.includes('=') && flagValueArgs.has(token)) { + i += 1; + } + continue; + } + + parsingTargets = true; + targets.push(token); + } + + return targets; +} + +export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] { + const launchMpvIndex = argv.findIndex((arg) => arg === '--launch-mpv'); + if (launchMpvIndex < 0) { + return []; + } + + const flagValueArgs = new Set([ + '--alang', + '--input-ipc-server', + '--log-file', + '--profile', + '--script', + '--script-opts', + '--scripts', + '--slang', + '--sub-file-paths', + '--ytdl-format', + ]); + + const extraArgs: string[] = []; + for (let i = launchMpvIndex + 1; i < argv.length; i += 1) { + const token = argv[i]; + if (!token) continue; + if (token === '--') { + break; + } + if (!token.startsWith('-')) { + break; + } + + extraArgs.push(token); + if (!token.includes('=') && flagValueArgs.has(token)) { + const value = argv[i + 1]; + if (value && value !== '--') { + extraArgs.push(value); + i += 1; + } + } + } + return extraArgs; } export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/src/main-entry.ts b/src/main-entry.ts index 50128139..b87fc58e 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -1,8 +1,10 @@ +import path from 'node:path'; import { spawn } from 'node:child_process'; import { app, dialog } from 'electron'; import { printHelp } from './cli/help'; import { configureEarlyAppPaths, + normalizeLaunchMpvExtraArgs, normalizeLaunchMpvTargets, normalizeStartupArgv, sanitizeStartupEnv, @@ -15,6 +17,7 @@ import { shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; 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 { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; @@ -32,6 +35,19 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void { } } +function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined { + const assets = resolvePackagedFirstRunPluginAssets({ + dirname: __dirname, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + }); + if (!assets) { + return undefined; + } + + return path.join(assets.pluginDirSource, 'main.lua'); +} + process.argv = normalizeStartupArgv(process.argv, process.env); applySanitizedEnv(sanitizeStartupEnv(process.env)); configureEarlyAppPaths(app); @@ -68,6 +84,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { dialog.showErrorBox(title, content); }, }), + normalizeLaunchMpvExtraArgs(process.argv), + process.execPath, + resolveBundledWindowsMpvPluginEntrypoint(), ); app.exit(result.ok ? 0 : 1); }); diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 83bdb9b8..0af9b271 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -41,21 +41,57 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => { }); test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => { - assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [ + assert.deepEqual( + buildWindowsMpvLaunchArgs( + ['C:\\a.mkv', 'C:\\b.mkv'], + [], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + ), + [ '--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', + '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', '--secondary-sub-visibility=no', + '--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket', 'C:\\a.mkv', 'C:\\b.mkv', ]); }); +test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () => { + assert.deepEqual( + buildWindowsMpvLaunchArgs( + [], + [], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + ), + [ + '--player-operation-mode=pseudo-gui', + '--force-window=immediate', + '--idle=yes', + '--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', + ], + ); +}); + test('launchWindowsMpv reports missing mpv path', () => { const errors: string[] = []; const result = launchWindowsMpv( @@ -82,13 +118,16 @@ test('launchWindowsMpv spawns detached mpv with targets', () => { calls.push(args.join('|')); }, }), + [], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', ); assert.equal(result.ok, true); assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe'); assert.deepEqual(calls, [ 'C:\\mpv\\mpv.exe', - '--player-operation-mode=pseudo-gui|--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|C:\\video.mkv', + '--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv', ]); }); diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index e67da769..e6970f4e 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -33,17 +33,36 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string { return ''; } -export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] { +export function buildWindowsMpvLaunchArgs( + targets: string[], + extraArgs: string[] = [], + binaryPath?: string, + pluginEntrypointPath?: string, +): string[] { + const launchIdle = targets.length === 0; + const scriptOpts = + typeof binaryPath === 'string' && binaryPath.trim().length > 0 + ? `--script-opts=subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')},subminer-socket_path=\\\\.\\pipe\\subminer-socket` + : null; + const scriptEntrypoint = + typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0 + ? `--script=${pluginEntrypointPath.trim()}` + : null; + return [ '--player-operation-mode=pseudo-gui', + '--force-window=immediate', + ...(launchIdle ? ['--idle=yes'] : []), + ...(scriptEntrypoint ? [scriptEntrypoint] : []), '--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', + '--sub-file-paths=subs;subtitles', '--sid=auto', '--secondary-sid=auto', '--secondary-sub-visibility=no', + ...(scriptOpts ? [scriptOpts] : []), ...extraArgs, ...targets, ]; @@ -53,6 +72,8 @@ export function launchWindowsMpv( targets: string[], deps: WindowsMpvLaunchDeps, extraArgs: string[] = [], + binaryPath?: string, + pluginEntrypointPath?: string, ): { ok: boolean; mpvPath: string } { const mpvPath = resolveWindowsMpvPath(deps); if (!mpvPath) { @@ -64,7 +85,10 @@ export function launchWindowsMpv( } try { - deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs)); + deps.spawnDetached( + mpvPath, + buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath), + ); return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error);