From 4813ce1fea4c5b21557d10bfca3432a58a3e8ce8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 19 May 2026 21:12:58 -0700 Subject: [PATCH] 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 --- src/main/main-wiring.test.ts | 2 +- src/main/runtime/autoplay-ready-gate.test.ts | 40 +++++++++++++++++++ src/main/runtime/autoplay-ready-gate.ts | 15 +++++-- ...autoplay-tokenization-warm-release.test.ts | 17 ++++++++ .../autoplay-tokenization-warm-release.ts | 2 +- src/renderer/overlay-legacy-cleanup.test.ts | 2 +- 6 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 77335e99..03edb3a6 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -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 \(\) => \{(?[\s\S]*?)\n \},/, + /markActiveVideoWatched:\s*async\s*\(\)\s*=>\s*\{(?[\s\S]*?)\}\s*,/, )?.groups?.body; assert.ok(actionBlock); diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index 07e0a381..202f1441 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -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> = []; + 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 }) => { + 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, []); +}); diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index 56fe369e..b5044d8d 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -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); }; diff --git a/src/main/runtime/autoplay-tokenization-warm-release.test.ts b/src/main/runtime/autoplay-tokenization-warm-release.test.ts index be5faa5a..350401ff 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.test.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.test.ts @@ -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, []); +}); diff --git a/src/main/runtime/autoplay-tokenization-warm-release.ts b/src/main/runtime/autoplay-tokenization-warm-release.ts index 13c1bd9b..ae6112e4 100644 --- a/src/main/runtime/autoplay-tokenization-warm-release.ts +++ b/src/main/runtime/autoplay-tokenization-warm-release.ts @@ -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(); diff --git a/src/renderer/overlay-legacy-cleanup.test.ts b/src/renderer/overlay-legacy-cleanup.test.ts index dd971443..3d888a0d 100644 --- a/src/renderer/overlay-legacy-cleanup.test.ts +++ b/src/renderer/overlay-legacy-cleanup.test.ts @@ -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*\{(?[\s\S]*?)\n\}/, + /\.subtitle-sidebar-content\s*\{(?[\s\S]*?)\s*\}/, )?.groups?.body; assert.ok(sidebarContentBlock);