mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user