feat(anki): add media timing review before card creation (#203)

This commit is contained in:
2026-09-04 01:40:56 -07:00
committed by GitHub
parent 99266294b8
commit 84f718043a
78 changed files with 7022 additions and 135 deletions
+131
View File
@@ -19,6 +19,13 @@ import type {
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types';
import type {
MediaTimingReviewActionResult,
MediaTimingReviewPreviewRequest,
MediaTimingReviewResolveRequest,
MediaTimingReviewWaveformRequest,
MediaTimingReviewWaveformResult,
} from '../../types/anki';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import {
parseMpvCommand,
@@ -99,6 +106,16 @@ export interface IpcServiceDeps {
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: (
request: MediaTimingReviewPreviewRequest,
) => Promise<MediaTimingReviewActionResult>;
getMediaTimingReviewWaveform?: (
request: MediaTimingReviewWaveformRequest,
) => Promise<MediaTimingReviewWaveformResult>;
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
resolveMediaTimingReview?: (
request: MediaTimingReviewResolveRequest,
) => MediaTimingReviewActionResult | Promise<MediaTimingReviewActionResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -222,6 +239,72 @@ function parseOverlayNotificationActionPayload(
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
}
function parseMediaTimingReviewPreviewRequest(
payload: unknown,
): MediaTimingReviewPreviewRequest | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (
typeof record.reviewId !== 'string' ||
!record.reviewId ||
typeof record.startTime !== 'number' ||
!Number.isFinite(record.startTime) ||
typeof record.endTime !== 'number' ||
!Number.isFinite(record.endTime)
) {
return null;
}
return {
reviewId: record.reviewId,
startTime: record.startTime,
endTime: record.endTime,
};
}
function parseMediaTimingReviewWaveformRequest(
payload: unknown,
): MediaTimingReviewWaveformRequest | null {
return parseMediaTimingReviewPreviewRequest(payload);
}
function parseMediaTimingReviewResolveRequest(
payload: unknown,
): MediaTimingReviewResolveRequest | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
if (typeof record.reviewId !== 'string' || !record.reviewId) return null;
const decision = record.decision;
if (!decision || typeof decision !== 'object') return null;
const decisionRecord = decision as Record<string, unknown>;
if (
decisionRecord.action === 'use-original' ||
decisionRecord.action === 'skip-media' ||
decisionRecord.action === 'discard'
) {
return { reviewId: record.reviewId, decision: { action: decisionRecord.action } };
}
if (
decisionRecord.action === 'confirm' &&
typeof decisionRecord.startTime === 'number' &&
Number.isFinite(decisionRecord.startTime) &&
typeof decisionRecord.endTime === 'number' &&
Number.isFinite(decisionRecord.endTime) &&
(decisionRecord.text === undefined ||
(typeof decisionRecord.text === 'string' && decisionRecord.text.trim().length > 0))
) {
return {
reviewId: record.reviewId,
decision: {
action: 'confirm',
startTime: decisionRecord.startTime,
endTime: decisionRecord.endTime,
...(decisionRecord.text === undefined ? {} : { text: decisionRecord.text }),
},
};
}
return null;
}
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
@@ -278,6 +361,10 @@ export interface IpcDepsRuntimeOptions {
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -371,6 +458,10 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
previewMediaTimingReview: options.previewMediaTimingReview,
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
resolveMediaTimingReview: options.resolveMediaTimingReview,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
@@ -498,6 +589,46 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewPreview,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewPreviewRequest(payload);
if (!request || !deps.previewMediaTimingReview) {
return { ok: false, message: 'Timing preview is unavailable.' };
}
return await deps.previewMediaTimingReview(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewWaveform,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewWaveformRequest(payload);
if (!request || !deps.getMediaTimingReviewWaveform) {
return { ok: false, message: 'Timing waveform is unavailable.' };
}
return await deps.getMediaTimingReviewWaveform(request);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
async (_event: unknown, reviewId: unknown) => {
if (typeof reviewId !== 'string' || !reviewId || !deps.stopMediaTimingReviewPreview) {
return { ok: false, message: 'Timing preview is unavailable.' };
}
return await deps.stopMediaTimingReviewPreview(reviewId);
},
);
ipc.handle(
IPC_CHANNELS.request.mediaTimingReviewResolve,
async (_event: unknown, payload: unknown) => {
const request = parseMediaTimingReviewResolveRequest(payload);
if (!request || !deps.resolveMediaTimingReview) {
return { ok: false, message: 'Timing review is unavailable.' };
}
return await deps.resolveMediaTimingReview(request);
},
);
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings();
});