fix: drop stale deferred autoplay-ready signals on media change

- autoplay-ready gate now stamps pending signal with mediaPath and discards it on flush if media has changed
- tokenization warm release skips signaling when current media path is cleared (null)
- tighten regex matchers in main-wiring and overlay-legacy-cleanup tests
This commit is contained in:
2026-05-19 21:12:58 -07:00
parent 167004b2c9
commit 21f74c014c
6 changed files with 72 additions and 6 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ function readMainSource(): string {
test('manual watched session action starts immersion tracker before marking watched', () => {
const source = readMainSource();
const actionBlock = source.match(
/markActiveVideoWatched: async \(\) => \{(?<body>[\s\S]*?)\n \},/,
/markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\}\s*,/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -192,3 +192,43 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea
true,
);
});
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
let currentMediaPath = '/media/video-1.mkv';
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => currentMediaPath,
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
currentMediaPath = '/media/video-2.mkv';
targetReady = true;
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
+12 -3
View File
@@ -23,6 +23,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: {
mediaPath: string;
payload: SubtitleData;
options?: { forceWhilePaused?: boolean };
} | null = null;
@@ -35,6 +36,9 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -47,8 +51,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const mediaPath = getSignalMediaPath();
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -116,7 +119,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { payload, options };
pendingAutoplayReadySignal = { mediaPath, payload, options };
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
);
@@ -137,6 +140,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null;
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
deps.logDebug(
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
);
return;
}
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
};
@@ -67,3 +67,20 @@ test('autoplay tokenization warm release skips stale media after warmup resolves
assert.deepEqual(calls, ['warmup']);
});
test('autoplay tokenization warm release skips signaling when current media is cleared', () => {
const calls: string[] = [];
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => null,
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
assert.deepEqual(calls, []);
});
@@ -15,7 +15,7 @@ export function createAutoplayTokenizationWarmRelease(deps: {
}): (mediaPath: string | null | undefined) => void {
const signalIfCurrent = (mediaPath: string): void => {
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
if (currentMediaPath && currentMediaPath !== mediaPath) {
if (!currentMediaPath || currentMediaPath !== mediaPath) {
return;
}
deps.signalAutoplayReady();
+1 -1
View File
@@ -43,7 +43,7 @@ test('renderer stylesheet only hides visible focus chrome on top-level overlay f
test('subtitle sidebar stylesheet keeps quoted font fallbacks and generic family', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css');
const sidebarContentBlock = cssSource.match(
/\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\n\}/,
/\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\s*\}/,
)?.groups?.body;
assert.ok(sidebarContentBlock);