fix(overlay): drive timing review playhead from mpv playback position

- Preview session observes time-pos, waits for loaded media before seeking, and acks each command
- Forward positions to the modal over media-timing-review:preview-position
- Cursor follows the real position, pauses while buffering, and stays at the clip end when done
- Replace the fixed end-of-clip timer with a 15s stall timeout
- Document preview cursor behavior and ownership in docs
This commit is contained in:
sudacode committed 2026-09-29 18:07:07 -07:00
1 parent 04b878dd93
commit fdb0a31fff
14 files changed
+335 -57

No files matched your search

+95 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { ChildProcess } from 'node:child_process';
import net from 'node:net';
import { describe, test } from 'node:test';
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
@@ -217,6 +218,15 @@ function createFakeSocket() {
socket.destroyed = false;
socket.write = (data) => {
writes.push(data);
const message = JSON.parse(data);
if (message.request_id !== undefined) {
queueMicrotask(() =>
socket.emit(
'data',
`${JSON.stringify({ request_id: message.request_id, error: 'success' })}\n`,
),
);
}
return true;
};
socket.end = () => undefined;
@@ -226,6 +236,85 @@ function createFakeSocket() {
return { socket, writes };
}
test('preview waits for loaded media before seeking and reports the actual playback position', async () => {
const { socket, writes } = createFakeSocket();
const child = new ChildProcess();
child.kill = () => true;
const positions: number[] = [];
const session = new MediaTimingPreviewSession({
spawnProcess: () => child,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
const position = (data: number) =>
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data })}\n`,
);
try {
await session.start({
mediaPath: '/video/show.mkv',
onPlaybackPosition: (time) => positions.push(time),
});
const playing = session.play(36, 42);
await Promise.resolve();
assert.equal(
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
false,
);
position(0);
await playing;
position(36);
position(39);
// No advancing position means no fabricated progress, even while audio is stalled.
assert.deepEqual(positions, [36, 39]);
position(41.9);
assert.deepEqual(positions, [36, 39, 41.9]);
await session.stop();
position(42);
assert.deepEqual(positions, [36, 39, 41.9]);
} finally {
session.dispose();
}
});
for (const action of ['stop', 'dispose'] as const) {
test(`${action} cancels a preview still waiting for media to load`, async () => {
const { socket, writes } = createFakeSocket();
const child = new ChildProcess();
child.kill = () => true;
const session = new MediaTimingPreviewSession({
spawnProcess: () => child,
connectSocket: () => {
queueMicrotask(() => socket.emit('connect'));
return socket as never;
},
removeSocketFile: () => undefined,
createSocketPath: () => '/tmp/review.sock',
});
try {
await session.start({ mediaPath: '/video/show.mkv' });
const pending = assert.rejects(session.play(36, 42), /closed|cancelled/);
await session[action]();
await pending;
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
);
assert.equal(
writes.some((line) => JSON.parse(line).command[0] === 'seek'),
false,
);
} finally {
session.dispose();
}
});
}
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 };
@@ -253,11 +342,16 @@ test('preview session plays once to the clip end and reports when mpv has draine
[
['observe_property', 1, 'eof-reached'],
['observe_property', 2, 'pause'],
['observe_property', 3, 'time-pos'],
],
);
// 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);
socket.emit(
'data',
`${JSON.stringify({ event: 'property-change', name: 'time-pos', data: 0 })}\n`,
);
writes.length = 0;
await session.play(12.25, 14.5);
@@ -265,8 +359,8 @@ test('preview session plays once to the clip end and reports when mpv has draine
writes.map((line) => JSON.parse(line).command),
[
['set_property', 'pause', true],
['seek', 12.25, 'absolute+exact'],
['set_property', 'end', '14.500'],
['seek', 12.25, 'absolute+exact'],
['set_property', 'pause', false],
],
);
+121 -6
View File
@@ -15,6 +15,8 @@ const CONNECT_RETRY_MS = 40;
*/
const EOF_OBSERVER_ID = 1;
const PAUSE_OBSERVER_ID = 2;
const POSITION_OBSERVER_ID = 3;
const PLAYER_RESPONSE_TIMEOUT_MS = 15_000;
export interface MediaTimingPreviewStartOptions {
mediaPath: string;
@@ -23,6 +25,7 @@ export interface MediaTimingPreviewStartOptions {
volume?: number;
/** The file keeps source timestamps (a cached remote window); seek with the original times. */
absoluteTimestamps?: boolean;
onPlaybackPosition?: (time: number) => void;
}
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
@@ -103,8 +106,21 @@ export class MediaTimingPreviewSession {
private disposed = false;
private readBuffer = '';
private playing = false;
private playSequence = 0;
private eofReached = false;
private paused = true;
private mediaReady = false;
private onPlaybackPosition: MediaTimingPreviewStartOptions['onPlaybackPosition'];
private nextRequestId = 0;
private readonly pendingCommands = new Map<
number,
{
resolve: () => void;
reject: (error: Error) => void;
}
>();
private readonly readyListeners = new Set<() => void>();
private readonly pendingWaits = new Set<(error: Error) => void>();
private readonly endedListeners = new Set<() => void>();
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
@@ -141,6 +157,8 @@ export class MediaTimingPreviewSession {
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
const mediaPath = options.mediaPath.trim();
if (!mediaPath) throw new Error('No media source is available for preview');
this.mediaReady = false;
this.onPlaybackPosition = options.onPlaybackPosition;
const socketPath = this.deps.createSocketPath();
this.socketPath = socketPath;
@@ -186,19 +204,35 @@ export class MediaTimingPreviewSession {
}
this.playing = false;
this.send(['set_property', 'pause', true]);
this.send(['seek', startTime, 'absolute+exact']);
const sequence = ++this.playSequence;
if (!this.mediaReady) {
await this.waitForPlayer((resolve) => {
this.readyListeners.add(resolve);
return () => this.readyListeners.delete(resolve);
});
}
// 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.
const commands = [
['set_property', 'pause', true],
['set_property', 'end', endTime.toFixed(3)],
['seek', startTime, 'absolute+exact'],
['set_property', 'pause', false],
];
for (const command of commands) {
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
await this.command(command);
}
if (sequence !== this.playSequence) throw new Error('Preview playback was cancelled');
// Discard paused/EOF observations from the previous clip before arming this playback.
this.eofReached = false;
this.paused = false;
this.playing = true;
}
async stop(): Promise<void> {
this.playSequence += 1;
this.playing = false;
this.rejectPendingWaits();
if (!this.socket || this.socket.destroyed) return;
this.send(['set_property', 'pause', true]);
}
@@ -227,6 +261,23 @@ export class MediaTimingPreviewSession {
} catch {
continue;
}
if (
typeof message === 'object' &&
message !== null &&
'request_id' in message &&
typeof message.request_id === 'number'
) {
const pending = this.pendingCommands.get(message.request_id);
if (pending) {
if ('error' in message && message.error === 'success') pending.resolve();
else
pending.reject(
new Error(
`Preview command failed: ${'error' in message ? String(message.error) : 'unknown error'}`,
),
);
}
}
if (
typeof message === 'object' &&
message !== null &&
@@ -241,6 +292,13 @@ export class MediaTimingPreviewSession {
}
private handlePropertyChange(name: unknown, data: unknown): void {
if (name === 'time-pos') {
if (typeof data !== 'number' || !Number.isFinite(data)) return;
this.mediaReady = true;
for (const listener of this.readyListeners) listener();
if (this.playing) this.onPlaybackPosition?.(data);
return;
}
if (name === 'eof-reached') this.eofReached = data === true;
else if (name === 'pause') this.paused = data === true;
else return;
@@ -255,6 +313,8 @@ export class MediaTimingPreviewSession {
private releaseResources(): void {
this.cancelRetryWait();
this.playing = false;
this.rejectPendingWaits();
try {
this.send(['quit']);
} catch {
@@ -283,6 +343,57 @@ export class MediaTimingPreviewSession {
this.socket.write(`${JSON.stringify({ command })}\n`);
}
private waitForPlayer(
subscribe: (resolve: () => void, reject: (error: Error) => void) => () => void,
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let unsubscribe = () => {};
const finish = (error?: Error) => {
if (settled) return;
settled = true;
this.deps.cancelSchedule(timeout);
unsubscribe();
this.pendingWaits.delete(fail);
if (error) reject(error);
else resolve();
};
const fail = (error: Error) => finish(error);
const timeout = this.deps.schedule(
() => fail(new Error('Timed out waiting for the preview player')),
PLAYER_RESPONSE_TIMEOUT_MS,
);
this.pendingWaits.add(fail);
try {
unsubscribe = subscribe(() => finish(), fail);
if (settled) unsubscribe();
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
});
}
private command(command: Array<string | number | boolean>): Promise<void> {
if (!this.socket || this.socket.destroyed)
return Promise.reject(new Error('Preview player is not ready'));
const socket = this.socket;
const requestId = ++this.nextRequestId;
return this.waitForPlayer((resolve, reject) => {
this.pendingCommands.set(requestId, { resolve, reject });
try {
socket.write(`${JSON.stringify({ command, request_id: requestId })}\n`);
} catch (error) {
this.pendingCommands.delete(requestId);
throw error;
}
return () => this.pendingCommands.delete(requestId);
});
}
private rejectPendingWaits(): void {
for (const reject of this.pendingWaits) reject(new Error('Preview player is closed'));
}
private async connectWithRetry(socketPath: string): Promise<void> {
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
while (!this.disposed && this.deps.now() < deadline) {
@@ -305,9 +416,13 @@ export class MediaTimingPreviewSession {
socket.on('data', (chunk: Buffer | string) => {
if (this.socket === socket) this.handleSocketData(chunk);
});
socket.once('close', () => this.finishPlayback());
socket.once('close', () => {
this.rejectPendingWaits();
this.finishPlayback();
});
this.send(['observe_property', EOF_OBSERVER_ID, 'eof-reached']);
this.send(['observe_property', PAUSE_OBSERVER_ID, 'pause']);
this.send(['observe_property', POSITION_OBSERVER_ID, 'time-pos']);
return;
} catch {
if (this.disposed) {
+7
View File
@@ -3009,6 +3009,13 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
}
}
},
onPreviewPosition: (position) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) {
window.webContents.send(IPC_CHANNELS.event.mediaTimingReviewPreviewPosition, position);
}
}
},
showStatus: (message) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
});
+26 -13
View File
@@ -410,14 +410,17 @@ test('media timing review downloads one window of a remote stream for the wavefo
endTime: 14.5,
},
]);
assert.deepEqual(previewStarts, [
{
mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv',
volume: 60,
absoluteTimestamps: true,
},
]);
assert.deepEqual(
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
[
{
mediaPath: '/tmp/window-7.5-14.5.mkv',
executablePath: 'mpv',
volume: 60,
absoluteTimestamps: true,
},
],
);
assert.deepEqual(previewPlays, [['/tmp/window-7.5-14.5.mkv', 9.5, 12.5]]);
assert.deepEqual(disposed, ['/tmp/window-7.5-14.5.mkv']);
});
@@ -512,9 +515,10 @@ test('media timing review falls back to the remote stream after one failed windo
audioStreamIndex: 2,
},
]);
assert.deepEqual(previewStarts, [
{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 },
]);
assert.deepEqual(
previewStarts.map(({ onPlaybackPosition, ...options }) => options),
[{ mediaPath: REMOTE_STREAM_URL, executablePath: 'mpv', volume: 60, audioTrackId: 3 }],
);
assert.deepEqual(previewPlays, [[REMOTE_STREAM_URL, 9.5, 12.5]]);
});
@@ -981,7 +985,8 @@ test('disposing owns a preview session whose startup is still pending', async ()
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
const positions: Array<{ reviewId: string; time: number }> = [];
const playback: { ended?: () => void; position?: (time: number) => void } = {};
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
@@ -997,7 +1002,9 @@ test('media timing review forwards the hidden player finishing a preview to the
getMpvExecutablePath: () => 'mpv',
generateWaveform: async () => [],
createPreviewSession: () => ({
start: async () => undefined,
start: async (options) => {
playback.position = options.onPlaybackPosition;
},
play: async () => undefined,
stop: async () => undefined,
onPlaybackEnded: (listener) => {
@@ -1012,6 +1019,7 @@ test('media timing review forwards the hidden player finishing a preview to the
onPreviewEnded: (reviewId) => {
endedReviewIds.push(reviewId);
},
onPreviewPosition: (position) => positions.push(position),
showStatus: () => undefined,
});
const pendingDecision = runtime.requestReview({
@@ -1031,6 +1039,9 @@ test('media timing review forwards the hidden player finishing a preview to the
},
);
assert.ok(playback.ended);
assert.ok(playback.position);
playback.position(11);
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
@@ -1038,6 +1049,8 @@ test('media timing review forwards the hidden player finishing a preview to the
await pendingDecision;
playback.ended();
assert.deepEqual(endedReviewIds, [payload.reviewId]);
playback.position(12);
assert.deepEqual(positions, [{ reviewId: payload.reviewId, time: 11 }]);
});
test('preview reports a stale review when the review ends during playback', async () => {
+9 -7
View File
@@ -5,6 +5,7 @@ import type {
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewRequest,
MediaTimingReviewPreviewPosition,
MediaTimingReviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
@@ -13,6 +14,7 @@ import type {
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
import type { MediaTimingPreviewStartOptions } from '../../core/services/media-timing-preview';
import type { MediaTimingFrameOptions } from '../../core/services/media-timing-frame';
import {
isRemoteMediaWindowSourcePath,
@@ -36,13 +38,7 @@ interface ReviewMpvClient {
}
interface PreviewSession {
start(options: {
mediaPath: string;
executablePath?: string;
audioTrackId?: number;
volume?: number;
absoluteTimestamps?: boolean;
}): Promise<void>;
start(options: MediaTimingPreviewStartOptions): 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(). */
@@ -113,6 +109,7 @@ export interface MediaTimingReviewRuntimeDeps {
openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise<boolean>;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
onPreviewPosition?: (position: MediaTimingReviewPreviewPosition) => void;
showStatus: (message: string) => void;
}
@@ -354,6 +351,11 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
await session.start({
mediaPath,
onPlaybackPosition: (time) => {
if (active === review && review.preview?.session === started) {
deps.onPreviewPosition?.({ reviewId: review.payload.reviewId, time });
}
},
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
...(window
+21
View File
@@ -71,6 +71,7 @@ import type {
ChangelogSnapshot,
MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
@@ -511,6 +512,26 @@ const electronAPI: ElectronAPI = {
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
onMediaTimingReviewPreviewEnded: onMediaTimingReviewPreviewEndedEvent,
onMediaTimingReviewPreviewPosition: (
callback: (position: MediaTimingReviewPreviewPosition) => void,
): void => {
ipcRenderer.on(
IPC_CHANNELS.event.mediaTimingReviewPreviewPosition,
(_event, payload: unknown) => {
if (
typeof payload !== 'object' ||
payload === null ||
!('reviewId' in payload) ||
typeof payload.reviewId !== 'string' ||
!('time' in payload) ||
typeof payload.time !== 'number' ||
!Number.isFinite(payload.time)
)
return;
callback({ reviewId: payload.reviewId, time: payload.time });
},
);
},
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
): Promise<MediaTimingReviewActionResult> =>
+36 -15
View File
@@ -3,6 +3,7 @@ import type {
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
@@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1;
const SPEECH_LEVEL_THRESHOLD = 0.3;
const SPEECH_TAIL_SECONDS = 0.15;
const MINIMUM_TRAILING_TRIM_SECONDS = 0.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;
/** Allow buffering and output-device delays without cutting off an advancing preview. */
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -303,30 +304,49 @@ export function createMediaTimingReviewModal(
previewTimer = null;
}
/**
* 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 {
function armPreviewTimeout(): void {
clearPreviewTimer();
previewTimer = setTimeout(() => {
stopPreview();
setStatus('Audio preview stopped responding. Try playing the selection again.', true);
}, PREVIEW_STALL_TIMEOUT_MS);
}
/** Playback position comes from mpv, so stalls and output latency cannot outrun the cursor. */
function setPreviewPlaying(playing: boolean, finished = false): void {
previewPlaying = playing;
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
clearPreviewTimer();
const track = ctx.dom.mediaTimingReviewSelectionTrack;
track.classList.remove('is-previewing');
track.classList.toggle('is-previewing', playing || finished);
if (finished) track.style.setProperty('--playhead-position', 'var(--selection-end)');
if (!playing) return;
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
void track.offsetWidth;
track.classList.add('is-previewing');
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
track.style.setProperty('--playhead-position', 'var(--selection-start)');
armPreviewTimeout();
}
function handlePreviewPosition(position: MediaTimingReviewPreviewPosition): void {
if (
!payload ||
payload.reviewId !== position.reviewId ||
!previewPlaying ||
!Number.isFinite(position.time)
)
return;
const time = clamp(position.time, selectionStart, selectionEnd);
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--playhead-position',
`${clamp(((time - timelineStart) / span) * 100, 0, 100)}%`,
);
armPreviewTimeout();
}
/** 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);
setPreviewPlaying(false, true);
setStatus('');
}
@@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal(
return {
openMediaTimingReviewModal,
handlePreviewEnded,
handlePreviewPosition,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
+3
View File
@@ -625,6 +625,9 @@ function registerModalOpenHandlers(): void {
mediaTimingReviewModal.handlePreviewEnded(reviewId);
});
});
window.electronAPI.onMediaTimingReviewPreviewPosition((position) => {
mediaTimingReviewModal.handlePreviewPosition(position);
});
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
+4 -15
View File
@@ -1687,7 +1687,7 @@ body:focus-visible,
--selection-end: 80%;
--original-start: 25%;
--original-end: 75%;
--playhead-duration: 1s;
--playhead-position: var(--selection-start);
position: relative;
height: 52px;
@@ -1803,10 +1803,10 @@ body:focus-visible,
.media-timing-review-playhead {
position: absolute;
z-index: 5;
z-index: 7;
top: 0;
bottom: 0;
left: var(--selection-start);
left: clamp(0px, var(--playhead-position), calc(100% - 2px));
width: 2px;
background: var(--ctp-yellow);
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
@@ -1816,16 +1816,6 @@ body:focus-visible,
.media-timing-review-track.is-previewing .media-timing-review-playhead {
opacity: 1;
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
}
@keyframes media-timing-review-playhead {
from {
left: var(--selection-start);
}
to {
left: var(--selection-end);
}
}
/* A thin line centered on the clip edge, so the line itself is the trim boundary. */
@@ -2128,8 +2118,7 @@ body:focus-visible,
animation: none;
}
.media-timing-review-track.is-loading::after,
.media-timing-review-track.is-previewing .media-timing-review-playhead {
.media-timing-review-track.is-loading::after {
animation: none;
}
}
+1
View File
@@ -167,6 +167,7 @@ export const IPC_CHANNELS = {
youtubePickerOpen: 'youtube:picker-open',
mediaTimingReviewOpen: 'media-timing-review:open',
mediaTimingReviewPreviewEnded: 'media-timing-review:preview-ended',
mediaTimingReviewPreviewPosition: 'media-timing-review:preview-position',
youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
sessionNumericSelectionStart: 'session:numeric-selection-start',
+5
View File
@@ -82,6 +82,11 @@ export interface MediaTimingReviewPreviewRequest {
endTime: number;
}
export interface MediaTimingReviewPreviewPosition {
reviewId: string;
time: number;
}
export interface MediaTimingReviewWaveformRequest {
reviewId: string;
startTime: number;
+4
View File
@@ -6,6 +6,7 @@ import type {
KikuMergePreviewResponse,
MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewFrameRequest,
@@ -556,6 +557,9 @@ export interface ElectronAPI {
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
onMediaTimingReviewPreviewEnded: (callback: (reviewId: string) => void) => void;
onMediaTimingReviewPreviewPosition: (
callback: (position: MediaTimingReviewPreviewPosition) => void,
) => void;
previewMediaTimingReview: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;