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
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Media timing review now keeps playback paused while it is open when the dictionary popup or subtitle hover pause ends, as with Hachidori popups closing when the review appears. Playback resumes after the review closes unless a dictionary popup is still open.
+2
View File
@@ -202,6 +202,8 @@ Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards. Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
Playback stays paused while the review is open, even if the dictionary popup or subtitle hover that paused it goes away. When the review closes, playback resumes if it was playing before the review or if the popup closed in the meantime. A dictionary popup that is still open keeps playback paused.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone. The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection. **Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
+13
View File
@@ -2051,7 +2051,20 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea
return command[0] === 'seek' || command[0] === 'sub-seek'; return command[0] === 'seek' || command[0] === 'sub-seek';
} }
function isMpvResumeCommand(command: readonly (string | number)[]): boolean {
return (
(command[0] === 'set_property' || command[0] === 'set') &&
command[1] === 'pause' &&
command[2] === 'no'
);
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void { function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
// behind an open timing review; the review applies them when it closes.
if (isMpvResumeCommand(rawCommand) && mediaTimingReviewRuntime.deferPlaybackResume()) {
return;
}
const command = const command =
resolveSanitizedSubtitleSeekCommand( resolveSanitizedSubtitleSeekCommand(
rawCommand, rawCommand,
+28 -2
View File
@@ -98,6 +98,7 @@ async function startActiveMediaTimingReview(
} = {}, } = {},
) { ) {
const previewCalls: Array<[number, number]> = []; const previewCalls: Array<[number, number]> = [];
const commands: Array<Array<string | number>> = [];
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void; let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => { const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve; publishPayload = resolve;
@@ -107,7 +108,7 @@ async function startActiveMediaTimingReview(
connected: true, connected: true,
currentVideoPath: '/video/show.mkv', currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null), requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined, send: ({ command }) => commands.push(command),
}), }),
getCurrentMediaPath: () => '/video/show.mkv', getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv', getMpvExecutablePath: () => 'mpv',
@@ -138,7 +139,7 @@ async function startActiveMediaTimingReview(
maxMediaDuration: options.maxMediaDuration ?? 30, 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 () => { 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, []); 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 () => { 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 });
+25 -1
View File
@@ -283,6 +283,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
let active: ActiveReview | null = null; let active: ActiveReview | null = null;
let currentRequest: ReviewRequestLifecycle | null = null; let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null; let pendingPauseRestore: ReviewMpvClient | null = null;
let resumeDeferred = false;
function restorePendingPlayback(): void { function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore; 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( function ensureWindow(
review: ActiveReview, review: ActiveReview,
range: RemoteMediaWindowRange, range: RemoteMediaWindowRange,
@@ -539,6 +560,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} finally { } finally {
if (currentRequest === lifecycle) { if (currentRequest === lifecycle) {
currentRequest = null; currentRequest = null;
resumeDeferredPlayback();
} }
lifecycle.markSettled(); lifecycle.markSettled();
} }
@@ -737,7 +759,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
if (!current) return; if (!current) return;
deps.clearFrameCache?.(); deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {}); 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'] }); current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
} }
} }
@@ -758,6 +781,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
getFrame, getFrame,
stopPreview, stopPreview,
resolveReview, resolveReview,
deferPlaybackResume,
dispose, dispose,
}; };
} }