fix(anki): hold playback paused while media timing review is open

- Defer overlay resume requests (popup closed, hover left) during an active review and apply them when the review closes
- Add deferPlaybackResume to the media timing review runtime and gate sendRendererMpvCommand on it
- Document the paused-playback behavior and add a changelog fragment
This commit is contained in:
2026-09-23 00:32:35 -07:00
parent 294e3a777e
commit 430120cc63
5 changed files with 72 additions and 3 deletions
+28 -2
View File
@@ -98,6 +98,7 @@ async function startActiveMediaTimingReview(
} = {},
) {
const previewCalls: Array<[number, number]> = [];
const commands: Array<Array<string | number>> = [];
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
@@ -107,7 +108,7 @@ async function startActiveMediaTimingReview(
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
@@ -138,7 +139,7 @@ async function startActiveMediaTimingReview(
maxMediaDuration: options.maxMediaDuration ?? 30,
});
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
return { runtime, payload: await openedPayload, pendingDecision, previewCalls, commands };
}
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
@@ -642,6 +643,31 @@ test('collectMediaTimingContextLines falls back to played history when no cues a
assert.deepEqual(context.next, []);
});
test('media timing review keeps an already-paused video paused when nothing asks to resume', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
test('media timing review holds overlay resume requests until the review closes', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
assert.equal(runtime.deferPlaybackResume(), true);
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(runtime.deferPlaybackResume(), false);
});
test('media timing review watchdog falls back when the renderer stops responding', async () => {
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
+25 -1
View File
@@ -283,6 +283,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
let active: ActiveReview | null = null;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
let resumeDeferred = false;
function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore;
@@ -292,6 +293,26 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
function resumeDeferredPlayback(): void {
if (!resumeDeferred) return;
resumeDeferred = false;
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
/**
* Holds an overlay request to resume playback (e.g. an auto-pause released because the
* dictionary popup closed) until the pending review ends, then applies it. Returns false
* when no review holds playback, so the caller should resume right away.
*/
function deferPlaybackResume(): boolean {
if (!active && !currentRequest) return false;
resumeDeferred = true;
return true;
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
@@ -539,6 +560,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} finally {
if (currentRequest === lifecycle) {
currentRequest = null;
resumeDeferredPlayback();
}
lifecycle.markSettled();
}
@@ -737,7 +759,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
if ((current.restorePlayback || resumeDeferred) && current.mpvClient.connected) {
resumeDeferred = false;
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
@@ -758,6 +781,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
getFrame,
stopPreview,
resolveReview,
deferPlaybackResume,
dispose,
};
}