diff --git a/changes/fix-timing-review-playback-hold.md b/changes/fix-timing-review-playback-hold.md new file mode 100644 index 00000000..96fc820a --- /dev/null +++ b/changes/fix-timing-review-playback-hold.md @@ -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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index a5470d6d..c5d8c757 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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. +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. **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. diff --git a/src/main.ts b/src/main.ts index 2edd3248..6c7ae8f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2051,7 +2051,20 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea 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 { + // 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 = resolveSanitizedSubtitleSeekCommand( rawCommand, diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index 85718b14..e3400d38 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -98,6 +98,7 @@ async function startActiveMediaTimingReview( } = {}, ) { const previewCalls: Array<[number, number]> = []; + const commands: Array> = []; let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void; const openedPayload = new Promise((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 }); diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index abb3258d..65826ea4 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -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, }; }