fix(anki): honor overlay pause that races timing review setup

- Track overlay pauses per review request so a pause arriving while setup reads mpv's pause state still cancels the playback restore
- Ignore cancelPlaybackResume when no review request is in flight
- Add a test covering the pause-read race and reset between requests
This commit is contained in:
2026-09-23 20:08:01 -07:00
parent c10174f9cd
commit 504faa51e0
2 changed files with 65 additions and 1 deletions
@@ -686,6 +686,64 @@ for (const paused of [true, false]) {
}); });
} }
test('media timing review honors an overlay pause that arrives while setup reads the pause state', async () => {
const commands: Array<Array<string | number>> = [];
const pauseReads: Array<(paused: boolean) => void> = [];
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: (name) =>
name === 'pause'
? new Promise((resolve) => pauseReads.push(resolve))
: Promise.resolve(name === 'duration' ? 100 : null),
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
queueMicrotask(() => {
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
});
return true;
},
showStatus: () => undefined,
});
const request = {
kind: 'sentence' as const,
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
};
const first = runtime.requestReview(request);
runtime.cancelPlaybackResume();
// mpv answered the read before the overlay's pause reached it.
pauseReads[0]!(false);
await first;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
commands.length = 0;
const second = runtime.requestReview(request);
pauseReads[1]!(false);
await second;
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
});
test('media timing review watchdog falls back when the renderer stops responding', async () => { test('media timing review watchdog falls back when the renderer stops responding', async () => {
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 }); const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
+7 -1
View File
@@ -284,6 +284,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
let currentRequest: ReviewRequestLifecycle | null = null; let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null; let pendingPauseRestore: ReviewMpvClient | null = null;
let resumeDeferred = false; let resumeDeferred = false;
/** Set when the overlay pauses during the current request, even before its setup reads the pause state. */
let restoreCancelled = false;
function restorePendingPlayback(): void { function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore; const mpvClient = pendingPauseRestore;
@@ -318,7 +320,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
* not resume it: drops both a held overlay resume and the review's own restore. * not resume it: drops both a held overlay resume and the review's own restore.
*/ */
function cancelPlaybackResume(): void { function cancelPlaybackResume(): void {
if (!currentRequest) return;
resumeDeferred = false; resumeDeferred = false;
restoreCancelled = true;
if (active) active.restorePlayback = false; if (active) active.restorePlayback = false;
} }
@@ -441,7 +445,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] = const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
setup.values; setup.values;
const pauseState = booleanProperty(pauseRaw); const pauseState = booleanProperty(pauseRaw);
pendingPauseRestore = pauseState === false ? mpvClient : null; // The pause read can predate an overlay pause that arrived during setup.
pendingPauseRestore = pauseState === false && !restoreCancelled ? mpvClient : null;
mpvClient.send({ command: ['set_property', 'pause', 'yes'] }); mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
if (lifecycle.isCancelled()) { if (lifecycle.isCancelled()) {
restorePendingPlayback(); restorePendingPlayback();
@@ -559,6 +564,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} }
const lifecycle = createReviewRequestLifecycle(); const lifecycle = createReviewRequestLifecycle();
currentRequest = lifecycle; currentRequest = lifecycle;
restoreCancelled = false;
try { try {
return await runReview(request, lifecycle); return await runReview(request, lifecycle);
} catch { } catch {