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', () => { test('manual watched session action starts immersion tracker before marking watched', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
/markActiveVideoWatched: async \(\) => \{(?<body>[\s\S]*?)\n \},/, /markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\}\s*,/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -192,3 +192,43 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea
true, 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 autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0; let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: { let pendingAutoplayReadySignal: {
mediaPath: string;
payload: SubtitleData; payload: SubtitleData;
options?: { forceWhilePaused?: boolean }; options?: { forceWhilePaused?: boolean };
} | null = null; } | null = null;
@@ -35,6 +36,9 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true; const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const maybeSignalPluginAutoplayReady = ( const maybeSignalPluginAutoplayReady = (
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
@@ -47,8 +51,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
const mediaPath = const mediaPath = getSignalMediaPath();
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200; const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({ const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -116,7 +119,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return; return;
} }
if (!isSignalTargetReady()) { if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { payload, options }; pendingAutoplayReadySignal = { mediaPath, payload, options };
deps.logDebug( deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`, `[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
); );
@@ -137,6 +140,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const pendingSignal = pendingAutoplayReadySignal; const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null; pendingAutoplayReadySignal = null;
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
deps.logDebug(
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
);
return;
}
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options); maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
}; };
@@ -67,3 +67,20 @@ test('autoplay tokenization warm release skips stale media after warmup resolves
assert.deepEqual(calls, ['warmup']); 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 { }): (mediaPath: string | null | undefined) => void {
const signalIfCurrent = (mediaPath: string): void => { const signalIfCurrent = (mediaPath: string): void => {
const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath()); const currentMediaPath = normalizeMediaPath(deps.getCurrentMediaPath());
if (currentMediaPath && currentMediaPath !== mediaPath) { if (!currentMediaPath || currentMediaPath !== mediaPath) {
return; return;
} }
deps.signalAutoplayReady(); 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', () => { test('subtitle sidebar stylesheet keeps quoted font fallbacks and generic family', () => {
const cssSource = readWorkspaceFile('src/renderer/style.css'); const cssSource = readWorkspaceFile('src/renderer/style.css');
const sidebarContentBlock = cssSource.match( const sidebarContentBlock = cssSource.match(
/\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\n\}/, /\.subtitle-sidebar-content\s*\{(?<body>[\s\S]*?)\s*\}/,
)?.groups?.body; )?.groups?.body;
assert.ok(sidebarContentBlock); assert.ok(sidebarContentBlock);