From 56a2a25312a8665e775fc85d198102e2e85d7ec6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 2 Sep 2026 18:44:37 -0700 Subject: [PATCH] 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 --- changes/media-timing-review.md | 2 +- docs-site/anki-integration.md | 2 +- .../services/media-timing-preview.test.ts | 85 +++++++++++++++++++ src/core/services/media-timing-preview.ts | 78 ++++++++++++++++- src/main.ts | 8 ++ src/main/runtime/media-timing-review.test.ts | 69 +++++++++++++++ src/main/runtime/media-timing-review.ts | 9 ++ src/preload.ts | 5 ++ src/renderer/modals/media-timing-review.ts | 18 +++- src/renderer/renderer.ts | 5 ++ src/shared/ipc/contracts.ts | 1 + src/types/runtime.ts | 1 + 12 files changed, 277 insertions(+), 6 deletions(-) diff --git a/changes/media-timing-review.md b/changes/media-timing-review.md index 27d49a0b..4654b8d3 100644 --- a/changes/media-timing-review.md +++ b/changes/media-timing-review.md @@ -1,5 +1,5 @@ type: added area: mining -- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform that flattens steady background noise so dialogue edges are easy to see, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle. +- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform that flattens steady background noise so dialogue edges are easy to see, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead that plays the clip to its true end even on high-latency outputs such as Bluetooth headphones, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle. - The timing review can pull any number of previous and next subtitle lines onto the card: `P`/`N` (or the Prev/Next steppers) add lines one at a time, Shift removes them, the sentence preview highlights exactly what the card will contain, and the clip range and line boundary markers on the waveform follow the added lines automatically. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 3641df71..cfa6904e 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -183,7 +183,7 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. -Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a mono mix, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness relative to the clip's own noise floor, so steady background music or ambience reads as a flat line while dialogue stands out. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range. +Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip; the preview ends when the hidden player has actually played the last sample, so output latency such as Bluetooth headphones does not cut the clip short. Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a mono mix, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness relative to the clip's own noise floor, so steady background music or ambience reads as a flat line while dialogue stands out. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range. The review can also pull adjacent subtitle lines onto the card. Press `P` or `N` (or use the Prev and Next steppers above the sentence preview) to add the previous or next line, as many times as lines are available; Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined line highlighted, so the card's sentence field is always visible before you confirm, and the clip start or end, along with the line-start and line-end rails on the waveform, follows the outermost added line, keeping the review's audio padding. Confirming writes the combined lines to the sentence field; the Reset button drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise only lines that already played are offered, and a clip capped by `media.maxMediaDuration` keeps the full combined sentence even when the audio cannot cover every added line. diff --git a/src/core/services/media-timing-preview.test.ts b/src/core/services/media-timing-preview.test.ts index bb9d1168..c13cb61d 100644 --- a/src/core/services/media-timing-preview.test.ts +++ b/src/core/services/media-timing-preview.test.ts @@ -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(); +}); diff --git a/src/core/services/media-timing-preview.ts b/src/core/services/media-timing-preview.ts index 578a3a2c..60f60863 100644 --- a/src/core/services/media-timing-preview.ts +++ b/src/core/services/media-timing-preview.ts @@ -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 = {}) { 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 { 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 { + 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) { diff --git a/src/main.ts b/src/main.ts index ba512865..ce5625f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' }), }); diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index d08c0172..1eec6406 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -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((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]); +}); diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index 3d052623..88bb9cee 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -42,6 +42,8 @@ interface PreviewSession { }): Promise; play(startTime: number, endTime: number): Promise; stop(): Promise; + /** 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; + /** 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({ diff --git a/src/preload.ts b/src/preload.ts index ad6f61a8..e44e2f56 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -191,6 +191,10 @@ const onOpenMediaTimingReviewEvent = IPC_CHANNELS.event.mediaTimingReviewOpen, (payload) => payload as MediaTimingReviewOpenPayload, ); +const onMediaTimingReviewPreviewEndedEvent = createQueuedIpcListenerWithPayload( + 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 => diff --git a/src/renderer/modals/media-timing-review.ts b/src/renderer/modals/media-timing-review.ts index 9f7ffbec..c18bbecc 100644 --- a/src/renderer/modals/media-timing-review.ts +++ b/src/renderer/modals/media-timing-review.ts @@ -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, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index c48eff5a..2137862a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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(); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 71e48306..bf6f9d98 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 45e66d6a..54031115 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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;