fix autoplay gate to hold pause until subtitle prime and tokenization re

- use pluginRuntimeConfig.autoStart (not effectivePluginRuntimeConfig) so pause-until-ready is preserved when attaching to a background app
- await subtitle priming before signaling autoplay readiness
- move sub-auto/sid defaults to start-file so they are not overwritten after track load
This commit is contained in:
2026-05-21 02:38:25 -07:00
parent 355d7d95b2
commit a53237f1ce
9 changed files with 135 additions and 28 deletions
@@ -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.
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -228,7 +228,7 @@ export async function runPlaybackCommandWithDeps(
: pluginRuntimeConfig;
const shouldPauseUntilOverlayReady =
effectivePluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
+14 -4
View File
@@ -244,6 +244,7 @@ async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
if (fs.existsSync(filePath)) return;
await new Promise<void>((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');
}
});
});
+25 -3
View File
@@ -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()
+40 -4
View File
@@ -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
+5 -1
View File
@@ -7,11 +7,15 @@ import { sendAppControlCommand } from '../../shared/app-control-client';
import { startAppControlServer } from './app-control-server';
async function waitForSocketPath(socketPath: string): Promise<void> {
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<void>((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 () => {
@@ -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<void> {
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<void>(() => {});
let resolvePrime!: () => void;
const prime = new Promise<void>((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']);
});
@@ -22,24 +22,41 @@ export function createAutoplayTokenizationWarmRelease(deps: {
deps.signalAutoplayReady();
};
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | 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);
})