fix(anki): wait for media timing previews to fully finish

- Wait for mpv to drain audio before ending previews
- Notify the review modal when playback actually completes
This commit is contained in:
2026-09-02 18:44:37 -07:00
parent c85db5e07e
commit 56a2a25312
12 changed files with 277 additions and 6 deletions
@@ -204,3 +204,88 @@ test('preview session bounds a connection attempt that never settles', async ()
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
assert.equal(connectAttempts, 1);
});
function createFakeSocket() {
const socket = new EventEmitter() as EventEmitter & {
destroyed: boolean;
write: (data: string) => boolean;
end: () => void;
destroy: () => void;
off: EventEmitter['off'];
};
const writes: string[] = [];
socket.destroyed = false;
socket.write = (data) => {
writes.push(data);
return true;
};
socket.end = () => undefined;
socket.destroy = () => {
socket.destroyed = true;
};
return { socket, writes };
}
test('preview session plays once to the clip end and reports when mpv has drained it', async () => {
const { socket, writes } = createFakeSocket();
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
child.kill = () => true;
const session = new MediaTimingPreviewSession({
platform: 'linux',
spawnProcess: () => child as never,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
let endedCount = 0;
session.onPlaybackEnded(() => {
endedCount += 1;
});
const property = (name: string, data: boolean): string =>
`${JSON.stringify({ event: 'property-change', name, data })}\n`;
await session.start({ mediaPath: '/video/show.mkv' });
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['observe_property', 1, 'eof-reached'],
['observe_property', 2, 'pause'],
],
);
// The observers' initial replies describe the idle paused player, not a finished preview.
socket.emit('data', property('eof-reached', false) + property('pause', true));
assert.equal(endedCount, 0);
writes.length = 0;
await session.play(12.25, 14.5);
assert.deepEqual(
writes.map((line) => JSON.parse(line).command),
[
['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'],
['set_property', 'pause', false],
],
);
// Events may arrive split across chunks. The decoder passing `end` flips eof-reached while
// audio still drains; only the keep-open pause that follows marks the preview as finished.
socket.emit('data', property('eof-reached', false) + property('pause', false).slice(0, 20));
socket.emit('data', property('pause', false).slice(20) + property('eof-reached', true));
assert.equal(endedCount, 0);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
socket.emit('data', property('pause', true));
assert.equal(endedCount, 1);
// Stopping early pauses without an end signal, and a later real EOF is not a preview end.
await session.play(1, 2);
socket.emit('data', property('eof-reached', false) + property('pause', false));
await session.stop();
socket.emit('data', property('pause', true) + property('eof-reached', true));
assert.equal(endedCount, 1);
session.dispose();
});
+76 -2
View File
@@ -8,6 +8,13 @@ import { randomUUID } from 'crypto';
const CONNECT_TIMEOUT_MS = 5_000;
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
const CONNECT_RETRY_MS = 40;
/**
* mpv flips eof-reached as soon as the decoder passes `end`, while its audio buffer is still
* draining; keep-open then pauses once the buffer has played out. A preview has ended when
* both have happened.
*/
const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2;
export interface MediaTimingPreviewStartOptions {
mediaPath: string;
@@ -94,6 +101,11 @@ export class MediaTimingPreviewSession {
resolve: () => void;
} | null = null;
private disposed = false;
private readBuffer = '';
private playing = false;
private eofReached = false;
private paused = true;
private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
this.deps = {
@@ -160,6 +172,11 @@ export class MediaTimingPreviewSession {
await this.connectWithRetry(socketPath);
}
/**
* Plays [startTime, endTime) once. mpv stops itself at `end` and, thanks to keep-open,
* pauses after draining the audio device, so the listener hears the whole clip even on
* high-latency outputs. onPlaybackEnded fires when mpv reports the end was reached.
*/
async play(startTime: number, endTime: number): Promise<void> {
if (!this.socket || this.socket.destroyed) {
throw new Error('Preview player is not ready');
@@ -168,18 +185,68 @@ export class MediaTimingPreviewSession {
throw new Error('Preview timing is invalid');
}
this.playing = false;
this.send(['set_property', 'pause', true]);
this.send(['set_property', 'ab-loop-a', startTime]);
this.send(['set_property', 'ab-loop-b', endTime]);
this.send(['seek', startTime, 'absolute+exact']);
// The option parser wants a time string; a raw JSON number is not accepted for `end`.
this.send(['set_property', 'end', endTime.toFixed(3)]);
this.send(['set_property', 'pause', false]);
// Only the seek's eof-reached=false and the later keep-open pause count for this play.
this.eofReached = false;
this.paused = false;
this.playing = true;
}
async stop(): Promise<void> {
this.playing = false;
if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]);
}
onPlaybackEnded(listener: () => void): void {
this.endedListeners.add(listener);
}
private finishPlayback(): void {
if (!this.playing) return;
this.playing = false;
for (const listener of this.endedListeners) listener();
}
private handleSocketData(chunk: Buffer | string): void {
this.readBuffer += chunk.toString();
let newline = this.readBuffer.indexOf('\n');
while (newline !== -1) {
const line = this.readBuffer.slice(0, newline).trim();
this.readBuffer = this.readBuffer.slice(newline + 1);
newline = this.readBuffer.indexOf('\n');
if (!line) continue;
let message: unknown;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
typeof message === 'object' &&
message !== null &&
'event' in message &&
message.event === 'property-change' &&
'name' in message &&
'data' in message
) {
this.handlePropertyChange(message.name, message.data);
}
}
}
private handlePropertyChange(name: unknown, data: unknown): void {
if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true;
else return;
if (this.playing && this.eofReached && this.paused) this.finishPlayback();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
@@ -234,6 +301,13 @@ export class MediaTimingPreviewSession {
throw new Error('Preview session is closed');
}
this.socket = socket;
this.readBuffer = '';
socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk);
});
socket.once('close', () => this.finishPlayback());
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
return;
} catch {
if (this.disposed) {
+8
View File
@@ -2927,6 +2927,14 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
endTime: range.endTime,
}),
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewEnded, reviewId);
}
}
},
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
@@ -102,6 +102,7 @@ async function startActiveMediaTimingReview(
previewCalls.push([startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -145,6 +146,7 @@ test('media timing review pauses playback, resolves exact timing, and restores p
previewCalls.push([startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -206,6 +208,7 @@ test('media timing review analyzes the visible range on the selected audio strea
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -316,6 +319,7 @@ function createRemoteReviewRuntime(options: {
options.previewPlays.push([mediaPath, startTime, endTime]);
},
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => {
options.disposed.push(mediaPath);
},
@@ -515,6 +519,7 @@ test('media timing review never downloads windows for local media', async () =>
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -644,6 +649,7 @@ test('media timing review does not resume playback when the prior state is unava
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async (payload) => {
@@ -690,6 +696,7 @@ test('media timing review restores playback when setup fails after pausing', asy
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
@@ -729,6 +736,7 @@ test('disposing an open review settles it with original timing and restores play
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: () => undefined,
dispose: () => undefined,
}),
openModal: async () => true,
@@ -752,3 +760,64 @@ test('disposing an open review settles it with original timing and restores play
['set_property', 'pause', 'no'],
]);
});
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
});
const runtime = createMediaTimingReviewRuntime({
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : null),
send: () => undefined,
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: (listener) => {
playback.ended = listener;
},
dispose: () => undefined,
}),
openModal: async (payload) => {
publishPayload(payload);
return true;
},
onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId);
},
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
kind: 'sentence',
text: '字幕',
startTime: 10,
endTime: 12,
audioPadding: 0,
maxMediaDuration: 30,
});
const payload = await openedPayload;
assert.deepEqual(
await runtime.previewRange({ reviewId: payload.reviewId, startTime: 10, endTime: 12 }),
{
ok: true,
},
);
assert.ok(playback.ended);
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
});
+9
View File
@@ -42,6 +42,8 @@ interface PreviewSession {
}): Promise<void>;
play(startTime: number, endTime: number): Promise<void>;
stop(): Promise<void>;
/** Fires when the player reaches the end of the clip started by play(). */
onPlaybackEnded(listener: () => void): void;
dispose(): void;
}
@@ -90,6 +92,8 @@ export interface MediaTimingReviewRuntimeDeps {
};
decisionTimeoutMs?: number;
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
}
@@ -285,6 +289,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const previous = review.preview;
const session = deps.createPreviewSession();
session.onPlaybackEnded(() => {
if (active === review && review.preview?.session === started) {
deps.onPreviewEnded?.(review.payload.reviewId);
}
});
const { audioTrackId, ...previewOptions } = review.previewOptions;
const started = session
.start({
+5
View File
@@ -191,6 +191,10 @@ const onOpenMediaTimingReviewEvent =
IPC_CHANNELS.event.mediaTimingReviewOpen,
(payload) => payload as MediaTimingReviewOpenPayload,
);
const onMediaTimingReviewPreviewEndedEvent = createQueuedIpcListenerWithPayload<string>(
IPC_CHANNELS.event.mediaTimingReviewPreviewEnded,
(payload) => (typeof payload === 'string' ? payload : ''),
);
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel,
@@ -469,6 +473,7 @@ const electronAPI: ElectronAPI = {
onOpenTsukihime: onOpenTsukihimeEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent,
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> =>
+16 -2
View File
@@ -11,6 +11,8 @@ const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 2;
const LINE_REVEAL_MARGIN_SECONDS = 1;
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
const PREVIEW_END_GRACE_MS = 2_500;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -209,7 +211,11 @@ export function createMediaTimingReviewModal(
previewTimer = null;
}
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
/**
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
* output latency such as Bluetooth headphones; the timer only covers a player that never does.
*/
function setPreviewPlaying(playing: boolean): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
@@ -222,7 +228,14 @@ export function createMediaTimingReviewModal(
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
void track.offsetWidth;
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
}
/** The hidden player reached the end of the clip and paused itself. */
function handlePreviewEnded(reviewId: string): void {
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
setPreviewPlaying(false);
setStatus('');
}
/** Callers that need to report a failure set their own status after stopping the preview. */
@@ -851,6 +864,7 @@ export function createMediaTimingReviewModal(
return {
openMediaTimingReviewModal,
handlePreviewEnded,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
+5
View File
@@ -591,6 +591,11 @@ function registerModalOpenHandlers(): void {
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
});
});
window.electronAPI.onMediaTimingReviewPreviewEnded((reviewId) => {
runGuarded('media-timing-review:preview-ended', () => {
mediaTimingReviewModal.handlePreviewEnded(reviewId);
});
});
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
+1
View File
@@ -148,6 +148,7 @@ export const IPC_CHANNELS = {
tsukihimeOpen: 'tsukihime:open',
youtubePickerOpen: 'youtube:picker-open',
mediaTimingReviewOpen: 'media-timing-review:open',
mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended',
youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
+1
View File
@@ -523,6 +523,7 @@ export interface ElectronAPI {
onOpenTsukihime: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void;
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;