mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 12:18:31 -07:00
fix(anki): prevent media timing review hangs and invalid ranges
- Reject stale or invalid timing actions - Fall back to original timing when the renderer stops responding
This commit is contained in:
@@ -22,6 +22,23 @@ describe('buildMediaTimingPreviewArgs', () => {
|
|||||||
assert.equal(args.at(-2), '--');
|
assert.equal(args.at(-2), '--');
|
||||||
assert.equal(args.at(-1), '/video/show.mkv');
|
assert.equal(args.at(-1), '/video/show.mkv');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('separates an option-like media path without adding optional audio arguments', () => {
|
||||||
|
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||||
|
mediaPath: '--fullscreen',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(args.at(-2), '--');
|
||||||
|
assert.equal(args.at(-1), '--fullscreen');
|
||||||
|
assert.equal(
|
||||||
|
args.some((arg) => arg.startsWith('--aid=')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
args.some((arg) => arg.startsWith('--volume=')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preview session handles socket errors after connecting', async () => {
|
test('preview session handles socket errors after connecting', async () => {
|
||||||
@@ -45,6 +62,31 @@ test('preview session handles socket errors after connecting', async () => {
|
|||||||
session.dispose();
|
session.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preview session keeps failed connection errors handled through destruction', async () => {
|
||||||
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
socket.destroy = () => {
|
||||||
|
socket.emit('error', new Error('socket failed again while closing'));
|
||||||
|
};
|
||||||
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
|
child.kill = () => true;
|
||||||
|
const times = [0, 0, 0, 6_000];
|
||||||
|
const session = new MediaTimingPreviewSession({
|
||||||
|
platform: 'linux',
|
||||||
|
spawnProcess: () => child as never,
|
||||||
|
connectSocket: () => {
|
||||||
|
queueMicrotask(() => socket.emit('error', new Error('connection failed')));
|
||||||
|
return socket as never;
|
||||||
|
},
|
||||||
|
now: () => times.shift() ?? 6_000,
|
||||||
|
removeSocketFile: () => undefined,
|
||||||
|
createSocketPath: () => '/tmp/review.sock',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
||||||
|
});
|
||||||
|
|
||||||
test('preview session rejects a connection that finishes after disposal', async () => {
|
test('preview session rejects a connection that finishes after disposal', async () => {
|
||||||
const socket = new net.Socket();
|
const socket = new net.Socket();
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||||
@@ -126,7 +168,12 @@ test('preview session bounds a connection attempt that never settles', async ()
|
|||||||
spawnProcess: () => child as never,
|
spawnProcess: () => child as never,
|
||||||
connectSocket: () => {
|
connectSocket: () => {
|
||||||
connectAttempts += 1;
|
connectAttempts += 1;
|
||||||
return new net.Socket();
|
const socket = new net.Socket();
|
||||||
|
socket.destroy = (() => {
|
||||||
|
socket.emit('error', new Error('socket failed while timing out'));
|
||||||
|
return socket;
|
||||||
|
}) as typeof socket.destroy;
|
||||||
|
return socket;
|
||||||
},
|
},
|
||||||
now: () => {
|
now: () => {
|
||||||
const current = nowMs;
|
const current = nowMs;
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export class MediaTimingPreviewSession {
|
|||||||
settled = true;
|
settled = true;
|
||||||
clearAttemptTimeout();
|
clearAttemptTimeout();
|
||||||
socket.off('connect', onConnect);
|
socket.off('connect', onConnect);
|
||||||
|
socket.on('error', () => {});
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
reject(error);
|
reject(error);
|
||||||
};
|
};
|
||||||
@@ -301,6 +302,7 @@ export class MediaTimingPreviewSession {
|
|||||||
timeout = null;
|
timeout = null;
|
||||||
socket.off('connect', onConnect);
|
socket.off('connect', onConnect);
|
||||||
socket.off('error', onError);
|
socket.off('error', onError);
|
||||||
|
socket.on('error', () => {});
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
reject(new Error('Timed out connecting to the hidden mpv preview player'));
|
reject(new Error('Timed out connecting to the hidden mpv preview player'));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export async function openMediaTimingReviewModal(
|
|||||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||||
sendOpen: () =>
|
sendOpen: () =>
|
||||||
openOverlayHostedModal(deps, {
|
openOverlayHostedModal(deps, {
|
||||||
channel: 'media-timing-review:open',
|
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||||
modal: MODAL,
|
modal: MODAL,
|
||||||
payload,
|
payload,
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, test } from 'node:test';
|
import { describe, test } from 'node:test';
|
||||||
|
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||||
import {
|
import {
|
||||||
buildMediaTimingReviewPayload,
|
buildMediaTimingReviewPayload,
|
||||||
createMediaTimingReviewRuntime,
|
createMediaTimingReviewRuntime,
|
||||||
@@ -63,6 +64,54 @@ describe('buildMediaTimingReviewPayload', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function startActiveMediaTimingReview(
|
||||||
|
options: {
|
||||||
|
maxMediaDuration?: number;
|
||||||
|
decisionTimeoutMs?: number;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const previewCalls: Array<[number, number]> = [];
|
||||||
|
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 : name === 'pause' ? true : null),
|
||||||
|
send: () => undefined,
|
||||||
|
}),
|
||||||
|
getCurrentMediaPath: () => '/video/show.mkv',
|
||||||
|
getMpvExecutablePath: () => 'mpv',
|
||||||
|
generateWaveform: async () => [],
|
||||||
|
decisionTimeoutMs: options.decisionTimeoutMs,
|
||||||
|
createPreviewSession: () => ({
|
||||||
|
start: async () => undefined,
|
||||||
|
play: async (startTime, endTime) => {
|
||||||
|
previewCalls.push([startTime, endTime]);
|
||||||
|
},
|
||||||
|
stop: async () => undefined,
|
||||||
|
dispose: () => undefined,
|
||||||
|
}),
|
||||||
|
openModal: async (payload) => {
|
||||||
|
publishPayload(payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showStatus: () => undefined,
|
||||||
|
});
|
||||||
|
const pendingDecision = runtime.requestReview({
|
||||||
|
kind: 'sentence',
|
||||||
|
text: '字幕',
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
audioPadding: 0,
|
||||||
|
maxMediaDuration: options.maxMediaDuration ?? 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
||||||
|
}
|
||||||
|
|
||||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const previewCalls: Array<[number, number]> = [];
|
const previewCalls: Array<[number, number]> = [];
|
||||||
@@ -189,6 +238,50 @@ test('media timing review analyzes the visible range on the selected audio strea
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
|
||||||
|
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
|
||||||
|
maxMediaDuration: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
|
||||||
|
{ ok: false, message: 'This timing review is no longer active.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: 'stale-review',
|
||||||
|
decision: { action: 'confirm', startTime: 10, endTime: 12 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'This timing review is no longer active.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'confirm', startTime: 10, endTime: 14 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({
|
||||||
|
reviewId: payload.reviewId,
|
||||||
|
decision: { action: 'confirm', startTime: 99, endTime: 100.5 },
|
||||||
|
}),
|
||||||
|
{ ok: false, message: 'The selected timing range is invalid.' },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
||||||
|
{ ok: true },
|
||||||
|
);
|
||||||
|
assert.deepEqual(await pendingDecision, { action: 'discard' });
|
||||||
|
assert.deepEqual(previewCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
||||||
|
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
||||||
|
|
||||||
|
assert.deepEqual(await pendingDecision, { action: 'use-original' });
|
||||||
|
});
|
||||||
|
|
||||||
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
||||||
|
|
||||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||||
|
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
||||||
|
|
||||||
interface ReviewMpvClient {
|
interface ReviewMpvClient {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -49,6 +50,7 @@ export interface MediaTimingReviewRuntimeDeps {
|
|||||||
getMpvExecutablePath: () => string;
|
getMpvExecutablePath: () => string;
|
||||||
createPreviewSession: () => PreviewSession;
|
createPreviewSession: () => PreviewSession;
|
||||||
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
||||||
|
decisionTimeoutMs?: number;
|
||||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||||
showStatus: (message: string) => void;
|
showStatus: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,21 @@ function booleanProperty(value: unknown): boolean | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidMediaTimingRange(
|
||||||
|
payload: MediaTimingReviewOpenPayload,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Number.isFinite(startTime) &&
|
||||||
|
Number.isFinite(endTime) &&
|
||||||
|
startTime >= 0 &&
|
||||||
|
endTime > startTime &&
|
||||||
|
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
|
||||||
|
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMediaTimingReviewPayload(
|
export function buildMediaTimingReviewPayload(
|
||||||
request: MediaTimingReviewRequest,
|
request: MediaTimingReviewRequest,
|
||||||
options: { reviewId: string; mediaDuration?: number },
|
options: { reviewId: string; mediaDuration?: number },
|
||||||
@@ -178,7 +195,16 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
|||||||
return { action: 'use-original' };
|
return { action: 'use-original' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const decision = await decisionPromise;
|
const decisionWatchdog = setTimeout(
|
||||||
|
() => resolveDecision({ action: 'use-original' }),
|
||||||
|
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
let decision: MediaTimingReviewDecision;
|
||||||
|
try {
|
||||||
|
decision = await decisionPromise;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(decisionWatchdog);
|
||||||
|
}
|
||||||
await cleanupActiveReview();
|
await cleanupActiveReview();
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
@@ -210,16 +236,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
|||||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||||
return { ok: false, message: 'This timing review is no longer active.' };
|
return { ok: false, message: 'This timing review is no longer active.' };
|
||||||
}
|
}
|
||||||
if (
|
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
|
||||||
!Number.isFinite(request.startTime) ||
|
|
||||||
!Number.isFinite(request.endTime) ||
|
|
||||||
request.startTime < 0 ||
|
|
||||||
request.endTime <= request.startTime ||
|
|
||||||
(current.payload.maxMediaDuration > 0 &&
|
|
||||||
request.endTime - request.startTime > current.payload.maxMediaDuration + 0.001) ||
|
|
||||||
(current.payload.mediaDuration !== undefined &&
|
|
||||||
request.endTime > current.payload.mediaDuration + 0.001)
|
|
||||||
) {
|
|
||||||
return { ok: false, message: 'The selected preview range is invalid.' };
|
return { ok: false, message: 'The selected preview range is invalid.' };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -294,16 +311,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
|
|||||||
}
|
}
|
||||||
if (request.decision.action === 'confirm') {
|
if (request.decision.action === 'confirm') {
|
||||||
const { startTime, endTime } = request.decision;
|
const { startTime, endTime } = request.decision;
|
||||||
if (
|
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
|
||||||
!Number.isFinite(startTime) ||
|
|
||||||
!Number.isFinite(endTime) ||
|
|
||||||
startTime < 0 ||
|
|
||||||
endTime <= startTime ||
|
|
||||||
(current.payload.maxMediaDuration > 0 &&
|
|
||||||
endTime - startTime > current.payload.maxMediaDuration + 0.001) ||
|
|
||||||
(current.payload.mediaDuration !== undefined &&
|
|
||||||
endTime > current.payload.mediaDuration + 0.001)
|
|
||||||
) {
|
|
||||||
return { ok: false, message: 'The selected timing range is invalid.' };
|
return { ok: false, message: 'The selected timing range is invalid.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -69,6 +69,7 @@ import type {
|
|||||||
OverlayNotificationEventPayload,
|
OverlayNotificationEventPayload,
|
||||||
OverlayNotificationPosition,
|
OverlayNotificationPosition,
|
||||||
ChangelogSnapshot,
|
ChangelogSnapshot,
|
||||||
|
MediaTimingReviewActionResult,
|
||||||
MediaTimingReviewOpenPayload,
|
MediaTimingReviewOpenPayload,
|
||||||
MediaTimingReviewPreviewRequest,
|
MediaTimingReviewPreviewRequest,
|
||||||
MediaTimingReviewResolveRequest,
|
MediaTimingReviewResolveRequest,
|
||||||
@@ -468,13 +469,17 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||||
previewMediaTimingReview: (request: MediaTimingReviewPreviewRequest) =>
|
previewMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewPreviewRequest,
|
||||||
|
): Promise<MediaTimingReviewActionResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
||||||
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
|
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
|
||||||
stopMediaTimingReviewPreview: (reviewId: string) =>
|
stopMediaTimingReviewPreview: (reviewId: string): Promise<MediaTimingReviewActionResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
||||||
resolveMediaTimingReview: (request: MediaTimingReviewResolveRequest) =>
|
resolveMediaTimingReview: (
|
||||||
|
request: MediaTimingReviewResolveRequest,
|
||||||
|
): Promise<MediaTimingReviewActionResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewResolve, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewResolve, request),
|
||||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
handleTsukihimeKeydown: () => false,
|
handleTsukihimeKeydown: () => false,
|
||||||
|
handleMediaTimingReviewKeydown: () => false,
|
||||||
handleControllerSelectKeydown: () => {
|
handleControllerSelectKeydown: () => {
|
||||||
controllerSelectKeydownCount += 1;
|
controllerSelectKeydownCount += 1;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function createKeyboardHandlers(
|
|||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleMediaTimingReviewKeydown?: (e: KeyboardEvent) => boolean;
|
handleMediaTimingReviewKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -1080,7 +1080,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||||
options.handleMediaTimingReviewKeydown?.(e);
|
options.handleMediaTimingReviewKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user