diff --git a/changes/fix-background-attach-autoplay-gate.md b/changes/fix-background-attach-autoplay-gate.md new file mode 100644 index 00000000..fa64bb5b --- /dev/null +++ b/changes/fix-background-attach-autoplay-gate.md @@ -0,0 +1,5 @@ +type: fixed +area: launcher + +- Kept launcher-opened videos paused when attaching to an already-running background app until subtitle priming and tokenization readiness complete. +- Moved mpv plugin subtitle auto-selection to pre-load so launch-time subtitle choices are not reset after the video opens. diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index ec5a93f6..c3634464 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -261,7 +261,7 @@ test('plugin auto-start playback attaches a warm background app through the laun }); assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']); - assert.equal(receivedStartMpvOptions[0]?.startPaused, false); + assert.equal(receivedStartMpvOptions[0]?.startPaused, true); assert.equal( (receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined) ?.autoStart, diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index e2a82550..86d8a9b3 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -228,7 +228,7 @@ export async function runPlaybackCommandWithDeps( : pluginRuntimeConfig; const shouldPauseUntilOverlayReady = - effectivePluginRuntimeConfig.autoStart && + pluginRuntimeConfig.autoStart && pluginRuntimeConfig.autoStartVisibleOverlay && pluginRuntimeConfig.autoStartPauseUntilReady; diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index e9b3201d..54cf133b 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -244,6 +244,7 @@ async function waitForFile(filePath: string, timeoutMs = 1500): Promise { if (fs.existsSync(filePath)) return; await new Promise((resolve) => setTimeout(resolve, 50)); } + throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`); } async function startFakeControlServer( @@ -270,10 +271,19 @@ const server = net.createServer((socket) => { let buffer = ''; socket.on('data', (chunk) => { buffer += chunk.toString('utf8'); - const line = buffer.split(/\\r?\\n/, 1)[0]; - if (!line) return; - fs.appendFileSync(logPath, line + '\\n'); - socket.end(JSON.stringify({ ok: true }) + '\\n'); + let handledLine = false; + while (true) { + const newlineMatch = buffer.match(/\\r?\\n/); + if (!newlineMatch || newlineMatch.index === undefined) break; + const line = buffer.slice(0, newlineMatch.index).trim(); + buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length); + if (!line) continue; + fs.appendFileSync(logPath, line + '\\n'); + handledLine = true; + } + if (handledLine) { + socket.end(JSON.stringify({ ok: true }) + '\\n'); + } }); }); diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index bb17b36d..9f077618 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -60,17 +60,31 @@ function M.create(ctx) return state.auto_start_retry_generation end - local function rearm_managed_subtitle_defaults() + local function has_matching_subminer_socket() if not process.has_matching_mpv_ipc_socket(opts.socket_path) then return false end + return true + end + local function rearm_managed_subtitle_load_defaults() + if not has_matching_subminer_socket() 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 refresh_managed_subtitle_autoloading() + if not has_matching_subminer_socket() then + return false + end + mp.set_property_native("sub-auto", "fuzzy") + return true + end + local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt) if generation ~= state.auto_start_retry_generation then return @@ -83,7 +97,7 @@ function M.create(ctx) return end - local has_matching_socket = rearm_managed_subtitle_defaults() + local has_matching_socket = refresh_managed_subtitle_autoloading() if not has_matching_socket then if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function() @@ -109,6 +123,13 @@ function M.create(ctx) schedule_aniskip_fetch("overlay-start", 0.8) end + local function on_start_file() + if state.pending_reload_media_identity ~= nil then + return + end + rearm_managed_subtitle_load_defaults() + end + local function on_file_loaded() local media_identity = resolve_media_identity() local retry_generation = next_auto_start_retry_generation() @@ -151,7 +172,7 @@ function M.create(ctx) return end - rearm_managed_subtitle_defaults() + refresh_managed_subtitle_autoloading() schedule_aniskip_fetch("file-loaded", 0) end @@ -165,6 +186,7 @@ function M.create(ctx) end local function register_lifecycle_hooks() + mp.register_event("start-file", on_start_file) mp.register_event("file-loaded", on_file_loaded) mp.register_event("shutdown", on_shutdown) mp.register_event("file-loaded", function() diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index fbad4042..1476cdd4 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -1095,18 +1095,54 @@ do }, }) assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err)) - fire_event(recorded, "file-loaded") + fire_event(recorded, "start-file") assert_true( has_property_set(recorded.property_sets, "sub-auto", "fuzzy"), - "managed file-loaded should rearm sub-auto for idle mpv sessions" + "managed start-file should rearm sub-auto before mpv loads tracks" ) assert_true( has_property_set(recorded.property_sets, "sid", "auto"), - "managed file-loaded should rearm primary subtitle selection for idle mpv sessions" + "managed start-file should rearm primary subtitle selection before mpv loads tracks" ) assert_true( has_property_set(recorded.property_sets, "secondary-sid", "auto"), - "managed file-loaded should rearm secondary subtitle selection for idle mpv sessions" + "managed start-file should rearm secondary subtitle selection before mpv loads tracks" + ) + fire_event(recorded, "file-loaded") + assert_true( + count_property_set(recorded.property_sets, "sid", "auto") == 1, + "managed file-loaded should not reset primary subtitle selection after mpv loads tracks" + ) + assert_true( + count_property_set(recorded.property_sets, "secondary-sid", "auto") == 1, + "managed file-loaded should not reset secondary subtitle selection after mpv loads tracks" + ) +end + +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "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 attached subtitle rearm scenario: " .. tostring(err)) + fire_event(recorded, "start-file") + fire_event(recorded, "file-loaded") + assert_true( + count_property_set(recorded.property_sets, "sid", "auto") == 1, + "attached background app path should select primary subtitle before load only" + ) + assert_true( + count_property_set(recorded.property_sets, "secondary-sid", "auto") == 1, + "attached background app path should select secondary subtitle before load only" ) end diff --git a/src/main/runtime/app-control-server.test.ts b/src/main/runtime/app-control-server.test.ts index 737bf2f4..41c899b8 100644 --- a/src/main/runtime/app-control-server.test.ts +++ b/src/main/runtime/app-control-server.test.ts @@ -7,11 +7,15 @@ import { sendAppControlCommand } from '../../shared/app-control-client'; import { startAppControlServer } from './app-control-server'; async function waitForSocketPath(socketPath: string): Promise { - const deadline = Date.now() + 1000; + const timeoutMs = 1000; + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (fs.existsSync(socketPath)) return; await new Promise((resolve) => setTimeout(resolve, 10)); } + throw new Error( + `Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`, + ); } test('app control server dispatches argv requests and replies ok', async () => { diff --git a/src/main/runtime/autoplay-tokenization-warm-release.test.ts b/src/main/runtime/autoplay-tokenization-warm-release.test.ts index 17ff7f92..52fd8a4b 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.test.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.test.ts @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release'; +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + test('autoplay tokenization warm release signals immediately when warmups are ready', () => { const calls: string[] = []; const release = createAutoplayTokenizationWarmRelease({ @@ -45,14 +49,17 @@ test('autoplay tokenization warm release primes subtitles before waiting for war resolveWarmup(); await warmup; - await Promise.resolve(); + await flushMicrotasks(); assert.deepEqual(calls, ['prime', 'warmup', 'signal']); }); -test('autoplay tokenization warm release does not await subtitle priming before signaling ready media', async () => { +test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => { const calls: string[] = []; - const never = new Promise(() => {}); + let resolvePrime!: () => void; + const prime = new Promise((resolve) => { + resolvePrime = resolve; + }); const release = createAutoplayTokenizationWarmRelease({ isTokenizationWarmupReady: () => true, startTokenizationWarmups: async () => { @@ -61,7 +68,7 @@ test('autoplay tokenization warm release does not await subtitle priming before getCurrentMediaPath: () => '/tmp/video.mkv', primeCurrentSubtitle: () => { calls.push('prime'); - return never; + return prime; }, signalAutoplayReady: () => calls.push('signal'), warn: () => {}, @@ -70,6 +77,12 @@ test('autoplay tokenization warm release does not await subtitle priming before release('/tmp/video.mkv'); await Promise.resolve(); + assert.deepEqual(calls, ['prime']); + + resolvePrime(); + await prime; + await Promise.resolve(); + assert.deepEqual(calls, ['prime', 'signal']); }); diff --git a/src/main/runtime/autoplay-tokenization-warm-release.ts b/src/main/runtime/autoplay-tokenization-warm-release.ts index 9ed46391..8e09dd25 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.ts @@ -22,24 +22,41 @@ export function createAutoplayTokenizationWarmRelease(deps: { deps.signalAutoplayReady(); }; + const primeSubtitleForRelease = (mediaPath: string): Promise | null => { + if (!deps.primeCurrentSubtitle) { + return null; + } + try { + return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => { + deps.warn('Startup subtitle priming failed before autoplay readiness release:', error); + }); + } catch (error) { + deps.warn('Startup subtitle priming failed before autoplay readiness release:', error); + return null; + } + }; + return (mediaPath) => { const normalizedPath = normalizeMediaPath(mediaPath); if (!normalizedPath) { return; } - try { - void Promise.resolve(deps.primeCurrentSubtitle?.(normalizedPath)).catch((error) => { - deps.warn('Startup subtitle priming failed before autoplay readiness release:', error); - }); - } catch (error) { - deps.warn('Startup subtitle priming failed before autoplay readiness release:', error); - } + const primePromise = primeSubtitleForRelease(normalizedPath); if (deps.isTokenizationWarmupReady()) { - signalIfCurrent(normalizedPath); + if (!primePromise) { + signalIfCurrent(normalizedPath); + return; + } + void primePromise.then(() => { + signalIfCurrent(normalizedPath); + }); return; } - void deps - .startTokenizationWarmups() + const warmupPromise = deps.startTokenizationWarmups(); + const readinessPromise = primePromise + ? Promise.all([primePromise, warmupPromise]).then(() => {}) + : warmupPromise; + void readinessPromise .then(() => { signalIfCurrent(normalizedPath); })