mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 12:18:31 -07:00
feat(anki): add media timing review before card creation
- Add draggable timing previews with audio playback and exact media ranges - Support cancellation choices, including deleting existing cards
This commit is contained in:
+38
-2
@@ -28,6 +28,8 @@ import {
|
||||
KikuMergePreviewResponse,
|
||||
NotificationOptions,
|
||||
type WordCardKind,
|
||||
type MediaTimingReviewDecision,
|
||||
type MediaTimingReviewRequest,
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||
@@ -238,6 +240,9 @@ export class AnkiIntegration {
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private mediaTimingReviewCallback:
|
||||
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||
| null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
@@ -509,6 +514,7 @@ export class AnkiIntegration {
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: (
|
||||
@@ -566,6 +572,7 @@ export class AnkiIntegration {
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||
isUpdateInProgress: () => this.updateInProgress,
|
||||
setUpdateInProgress: (value) => {
|
||||
this.updateInProgress = value;
|
||||
@@ -581,6 +588,7 @@ export class AnkiIntegration {
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -637,12 +645,14 @@ export class AnkiIntegration {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||
@@ -680,6 +690,7 @@ export class AnkiIntegration {
|
||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -799,6 +810,12 @@ export class AnkiIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
private removeKnownWordNote(noteId: number): void {
|
||||
if (this.knownWordCache.removeNote(noteId)) {
|
||||
this.notifyKnownWordCacheUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyKnownWordCacheUpdated(): void {
|
||||
if (!this.knownWordCacheUpdatedCallback) {
|
||||
return;
|
||||
@@ -1039,7 +1056,7 @@ export class AnkiIntegration {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
await this.getMpvVolumeScale(),
|
||||
@@ -1072,7 +1089,7 @@ export class AnkiIntegration {
|
||||
videoPath,
|
||||
mediaRange.startTime,
|
||||
mediaRange.endTime,
|
||||
this.config.media?.audioPadding,
|
||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||
{
|
||||
fps: this.config.media?.animatedFps,
|
||||
maxWidth: this.config.media?.animatedMaxWidth,
|
||||
@@ -1723,6 +1740,25 @@ export class AnkiIntegration {
|
||||
this.consumeSubtitleMiningContextCallback = callback;
|
||||
}
|
||||
|
||||
setMediaTimingReviewCallback(
|
||||
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
||||
): void {
|
||||
this.mediaTimingReviewCallback = callback;
|
||||
}
|
||||
|
||||
private async reviewMediaTiming(
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
...request,
|
||||
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
|
||||
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
|
||||
});
|
||||
}
|
||||
|
||||
resolveCurrentNoteId(noteId: number): number {
|
||||
let resolved = noteId;
|
||||
const seen = new Set<number>();
|
||||
|
||||
@@ -85,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
@@ -129,6 +130,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -201,6 +203,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
@@ -248,6 +251,7 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -335,6 +339,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
@@ -383,3 +388,47 @@ test('createSentenceCard relies on Anki progress notification without standalone
|
||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||
assert.deepEqual(statusMessages, []);
|
||||
});
|
||||
|
||||
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
|
||||
const events: string[] = [];
|
||||
const statusMessages: string[] = [];
|
||||
const { service } = createManualUpdateService({
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 4,
|
||||
currentSubEnd: 6,
|
||||
currentTimePos: 5,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: { Expression: { value: '単語' } },
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async (noteIds) => {
|
||||
events.push(`delete:${noteIds.join(',')}`);
|
||||
},
|
||||
},
|
||||
reviewMediaTiming: async () => ({ action: 'discard' }),
|
||||
removeKnownWordNote: (noteId) => {
|
||||
events.push(`cache:${noteId}`);
|
||||
},
|
||||
showStatusNotification: (message) => {
|
||||
statusMessages.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
await service.markLastCardAsAudioCard();
|
||||
|
||||
assert.deepEqual(events, ['delete:42', 'cache:42']);
|
||||
assert.deepEqual(statusMessages, ['Card deleted.']);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
const storedMedia: string[] = [];
|
||||
const requestedProperties: string[] = [];
|
||||
const audioVolumeScales: Array<number | undefined> = [];
|
||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||
|
||||
const deps: CardCreationDeps = {
|
||||
getConfig: () =>
|
||||
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
},
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (
|
||||
_path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
startTime,
|
||||
endTime,
|
||||
audioPadding,
|
||||
_audioStreamIndex,
|
||||
_normalizeAudio,
|
||||
volumeScale,
|
||||
) => {
|
||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||
audioVolumeScales.push(volumeScale);
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
@@ -122,17 +125,15 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||
};
|
||||
|
||||
const created = await new CardCreationService(deps).createSentenceCard(
|
||||
'字幕',
|
||||
12,
|
||||
14,
|
||||
'Subtitle',
|
||||
);
|
||||
const service = new CardCreationService(deps);
|
||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(addedFields[0], {
|
||||
@@ -144,7 +145,12 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||
assert.equal(addedFields.length, 1);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -74,6 +75,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -139,6 +141,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -173,6 +176,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => {
|
||||
@@ -238,6 +242,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -272,6 +277,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
recordCardsMinedCallback: () => {
|
||||
@@ -348,6 +354,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path) => {
|
||||
@@ -392,6 +399,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -454,6 +462,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||
@@ -495,6 +504,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -590,6 +600,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
@@ -634,6 +645,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -701,6 +713,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -733,6 +746,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
@@ -790,6 +804,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
@@ -822,6 +837,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
removeKnownWordNote: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
|
||||
@@ -3,7 +3,13 @@ import {
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
type CardKind,
|
||||
type MediaTimingReviewDecision,
|
||||
type MediaTimingReviewRequest,
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -55,6 +61,7 @@ interface CardCreationClient {
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||
retrieveMediaFile(filename: string): Promise<string>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
}
|
||||
|
||||
interface CardCreationMediaGenerator {
|
||||
@@ -138,12 +145,16 @@ interface CardCreationDeps {
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||
removeKnownWordNote: (noteId: number) => void;
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
trackLastAddedNoteId?: (noteId: number) => void;
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
reviewMediaTiming?: (
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
) => Promise<MediaTimingReviewDecision>;
|
||||
}
|
||||
|
||||
export class CardCreationService {
|
||||
@@ -260,6 +271,7 @@ export class CardCreationService {
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
|
||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
@@ -451,6 +463,27 @@ export class CardCreationService {
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
|
||||
const timingDecision = this.deps.reviewMediaTiming
|
||||
? await this.deps.reviewMediaTiming({
|
||||
kind: 'audio',
|
||||
text: mpvClient.currentSubText,
|
||||
startTime,
|
||||
endTime,
|
||||
noteId,
|
||||
})
|
||||
: ({ action: 'use-original' } as const);
|
||||
if (timingDecision.action === 'discard') {
|
||||
await this.deps.client.deleteNotes([noteId]);
|
||||
this.deps.removeKnownWordNote(noteId);
|
||||
this.deps.showStatusNotification('Card deleted.');
|
||||
return;
|
||||
}
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
}
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
let miscInfoFilename: string | null = null;
|
||||
@@ -471,6 +504,7 @@ export class CardCreationService {
|
||||
mpvClient.currentVideoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
);
|
||||
|
||||
if (audioBuffer) {
|
||||
@@ -492,6 +526,7 @@ export class CardCreationService {
|
||||
startTime,
|
||||
endTime,
|
||||
animatedLeadInSeconds,
|
||||
exactReviewedRange,
|
||||
);
|
||||
|
||||
const imageField = this.deps.getConfig().fields?.image;
|
||||
@@ -564,6 +599,24 @@ export class CardCreationService {
|
||||
|
||||
try {
|
||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||
const timingDecision = this.deps.reviewMediaTiming
|
||||
? await this.deps.reviewMediaTiming({
|
||||
kind: 'sentence',
|
||||
text: sentence,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
: ({ action: 'use-original' } as const);
|
||||
if (timingDecision.action === 'discard') {
|
||||
this.deps.showStatusNotification('Card creation cancelled.');
|
||||
return false;
|
||||
}
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
endTime = timingDecision.endTime;
|
||||
}
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
@@ -732,6 +785,7 @@ export class CardCreationService {
|
||||
generateAudio,
|
||||
generateImage,
|
||||
volumeScale,
|
||||
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
|
||||
});
|
||||
await this.deps.showNotification(noteId, label, 'media queued');
|
||||
return true;
|
||||
@@ -747,7 +801,12 @@ export class CardCreationService {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = audioSourcePath
|
||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||
? await this.mediaGenerateAudio(
|
||||
audioSourcePath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (audioBuffer) {
|
||||
@@ -765,7 +824,13 @@ export class CardCreationService {
|
||||
if (generateImage) {
|
||||
try {
|
||||
const imageFilename = this.generateImageFilename();
|
||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||
const imageBuffer = await this.generateImageBuffer(
|
||||
videoPath!,
|
||||
startTime,
|
||||
endTime,
|
||||
0,
|
||||
exactReviewedRange,
|
||||
);
|
||||
|
||||
const imageField = config.fields?.image;
|
||||
if (imageBuffer && imageField) {
|
||||
@@ -833,6 +898,7 @@ export class CardCreationService {
|
||||
videoPath: MediaInput,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
audioPaddingOverride?: number,
|
||||
): Promise<Buffer | null> {
|
||||
const mpvClient = this.deps.getMpvClient();
|
||||
if (!mpvClient) {
|
||||
@@ -843,7 +909,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
@@ -861,13 +927,16 @@ export class CardCreationService {
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
exactReviewedRange = false,
|
||||
): Promise<Buffer | null> {
|
||||
const mpvClient = this.deps.getMpvClient();
|
||||
if (!mpvClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = mpvClient.currentTimePos || 0;
|
||||
const timestamp = exactReviewedRange
|
||||
? startTime + (endTime - startTime) / 2
|
||||
: mpvClient.currentTimePos || 0;
|
||||
|
||||
if (this.deps.getConfig().media?.imageType === 'avif') {
|
||||
let imageStart = startTime;
|
||||
@@ -883,7 +952,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
imageStart,
|
||||
imageEnd,
|
||||
this.deps.getConfig().media?.audioPadding,
|
||||
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
|
||||
{
|
||||
fps: this.deps.getConfig().media?.animatedFps,
|
||||
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
||||
|
||||
@@ -261,6 +261,32 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: { word: 'Word' },
|
||||
knownWords: { highlightEnabled: true },
|
||||
};
|
||||
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
manager.appendFromNoteInfo({
|
||||
noteId: 42,
|
||||
fields: { Word: { value: '猫' } },
|
||||
});
|
||||
|
||||
assert.equal(manager.removeNote(42), true);
|
||||
assert.equal(manager.removeNote(42), false);
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
notes?: Record<string, unknown>;
|
||||
};
|
||||
assert.deepEqual(persisted.notes, {});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
|
||||
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
removeNote(noteId: number): boolean {
|
||||
if (!this.noteEntriesById.has(noteId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeNoteSnapshot(noteId);
|
||||
this.persistKnownWordCacheState();
|
||||
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearKnownWordCacheState(): void {
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
|
||||
@@ -44,6 +44,7 @@ function createWorkflowHarness() {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async () => undefined,
|
||||
deleteNotes: async () => undefined,
|
||||
},
|
||||
getConfig: () => ({
|
||||
fields: {
|
||||
@@ -61,6 +62,7 @@ function createWorkflowHarness() {
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
}),
|
||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||
removeKnownWordNote: (_noteId: number) => undefined,
|
||||
extractFields: (fields: Record<string, { value: string }>) => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
@@ -592,3 +594,62 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
|
||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const deletedNoteIds: number[][] = [];
|
||||
const removedKnownWordNoteIds: number[] = [];
|
||||
let appendedKnownWords = false;
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.client.deleteNotes = async (noteIds) => {
|
||||
deletedNoteIds.push(noteIds);
|
||||
};
|
||||
harness.deps.appendKnownWordsFromNoteInfo = () => {
|
||||
appendedKnownWords = true;
|
||||
};
|
||||
harness.deps.removeKnownWordNote = (noteId) => {
|
||||
removedKnownWordNoteIds.push(noteId);
|
||||
};
|
||||
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(deletedNoteIds, [[42]]);
|
||||
assert.deepEqual(removedKnownWordNoteIds, [42]);
|
||||
assert.equal(appendedKnownWords, false);
|
||||
assert.deepEqual(harness.updates, []);
|
||||
assert.deepEqual(harness.notifications, []);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const statusMessages: string[] = [];
|
||||
let removedKnownWord = false;
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.client.deleteNotes = async () => {
|
||||
throw new Error('delete failed');
|
||||
};
|
||||
harness.deps.removeKnownWordNote = () => {
|
||||
removedKnownWord = true;
|
||||
};
|
||||
harness.deps.showOsdNotification = (message) => {
|
||||
statusMessages.push(message);
|
||||
};
|
||||
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(removedKnownWord, false);
|
||||
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
||||
assert.ok(harness.warnings.length === 0);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
import type {
|
||||
CardKind,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewRequest,
|
||||
WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
@@ -14,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
@@ -43,6 +49,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
removeKnownWordNote: (noteId: number) => void;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
findDuplicateNote: (
|
||||
expression: string,
|
||||
@@ -102,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
|
||||
logWarn: (message: string, ...args: unknown[]) => void;
|
||||
logInfo: (message: string, ...args: unknown[]) => void;
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
reviewMediaTiming?: (
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
) => Promise<MediaTimingReviewDecision>;
|
||||
}
|
||||
|
||||
function normalizeSubtitleContextText(text: string): string {
|
||||
@@ -171,7 +181,6 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
@@ -207,10 +216,45 @@ export class NoteUpdateWorkflow {
|
||||
// Audio and image generation run sequentially and audio extraction can take tens of
|
||||
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||
// timings per generator clips whichever line is on screen when each one starts.
|
||||
const mediaTimingContext =
|
||||
let mediaTimingContext =
|
||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
if (mediaTimingContext) {
|
||||
const timingDecision = this.deps.reviewMediaTiming
|
||||
? await this.deps.reviewMediaTiming({
|
||||
kind: 'word',
|
||||
text: mediaTimingContext.text,
|
||||
startTime: mediaTimingContext.startTime,
|
||||
endTime: mediaTimingContext.endTime,
|
||||
noteId,
|
||||
})
|
||||
: ({ action: 'use-original' } as const);
|
||||
if (timingDecision.action === 'discard') {
|
||||
try {
|
||||
await this.deps.client.deleteNotes([noteId]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.deps.logError('Failed to delete discarded card:', message);
|
||||
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
this.deps.removeKnownWordNote(noteId);
|
||||
this.deps.showOsdNotification('Card deleted.');
|
||||
return;
|
||||
}
|
||||
if (timingDecision.action === 'confirm') {
|
||||
mediaTimingContext = {
|
||||
...mediaTimingContext,
|
||||
startTime: timingDecision.startTime,
|
||||
endTime: timingDecision.endTime,
|
||||
mediaPaddingSeconds: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
|
||||
@@ -148,6 +148,9 @@ export class PendingYoutubeMediaQueue {
|
||||
generateAudio: shouldGenerateAudio(config),
|
||||
generateImage: shouldGenerateImage(config),
|
||||
volumeScale,
|
||||
...(job.context?.mediaPaddingSeconds !== undefined
|
||||
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -275,7 +278,7 @@ export class PendingYoutubeMediaQueue {
|
||||
cachedMediaInput,
|
||||
job.startTime,
|
||||
job.endTime,
|
||||
config.media?.audioPadding,
|
||||
job.mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
job.volumeScale,
|
||||
@@ -309,6 +312,7 @@ export class PendingYoutubeMediaQueue {
|
||||
job.startTime,
|
||||
job.endTime,
|
||||
animatedLeadInSeconds,
|
||||
job.mediaPaddingSeconds,
|
||||
);
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
@@ -369,6 +373,7 @@ export class PendingYoutubeMediaQueue {
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
animatedLeadInSeconds = 0,
|
||||
mediaPaddingSeconds?: number,
|
||||
): Promise<Buffer | null> {
|
||||
const config = this.deps.getConfig();
|
||||
if (config.media?.imageType === 'avif') {
|
||||
@@ -376,7 +381,7 @@ export class PendingYoutubeMediaQueue {
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
config.media?.audioPadding,
|
||||
mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||
{
|
||||
fps: config.media?.animatedFps,
|
||||
maxWidth: config.media?.animatedMaxWidth,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
|
||||
generateAudio: boolean;
|
||||
generateImage: boolean;
|
||||
volumeScale?: number;
|
||||
mediaPaddingSeconds?: number;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
|
||||
@@ -54,6 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
mirrorMpvVolume: true,
|
||||
reviewTiming: false,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -196,6 +196,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.reviewTiming',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||
description:
|
||||
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
|
||||
@@ -21,6 +21,34 @@ function makeContext(ankiConnect: unknown): {
|
||||
return { context, warnings };
|
||||
}
|
||||
|
||||
test('media timing review is disabled by default and accepts a boolean override', () => {
|
||||
const defaultContext = makeContext({});
|
||||
applyAnkiConnectResolution(defaultContext.context);
|
||||
assert.equal(defaultContext.context.resolved.ankiConnect.media.reviewTiming, false);
|
||||
|
||||
const enabledContext = makeContext({ media: { reviewTiming: true } });
|
||||
applyAnkiConnectResolution(enabledContext.context);
|
||||
assert.equal(enabledContext.context.resolved.ankiConnect.media.reviewTiming, true);
|
||||
assert.deepEqual(enabledContext.warnings, []);
|
||||
});
|
||||
|
||||
test('modern media duration accepts zero as the disabled cap sentinel', () => {
|
||||
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
|
||||
applyAnkiConnectResolution(disabledCap.context);
|
||||
assert.equal(disabledCap.context.resolved.ankiConnect.media.maxMediaDuration, 0);
|
||||
assert.deepEqual(disabledCap.warnings, []);
|
||||
|
||||
const invalidCap = makeContext({ media: { maxMediaDuration: -1 } });
|
||||
applyAnkiConnectResolution(invalidCap.context);
|
||||
assert.equal(
|
||||
invalidCap.context.resolved.ankiConnect.media.maxMediaDuration,
|
||||
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||
);
|
||||
assert.ok(
|
||||
invalidCap.warnings.some((warning) => warning.path === 'ankiConnect.media.maxMediaDuration'),
|
||||
);
|
||||
});
|
||||
|
||||
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
nPlusOne: { highlightEnabled: true },
|
||||
|
||||
@@ -19,6 +19,7 @@ export function applyModernMediaResolution(
|
||||
'syncAnimatedImageToWordAudio',
|
||||
'normalizeAudio',
|
||||
'mirrorMpvVolume',
|
||||
'reviewTiming',
|
||||
] as const) {
|
||||
applyModernValue(
|
||||
context,
|
||||
@@ -128,18 +129,28 @@ export function applyModernMediaResolution(
|
||||
'Expected non-negative number.',
|
||||
);
|
||||
|
||||
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
||||
applyModernValue(
|
||||
context,
|
||||
media,
|
||||
key,
|
||||
`ankiConnect.media.${key}`,
|
||||
asPositiveNumber,
|
||||
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||
(value) => {
|
||||
context.resolved.ankiConnect.media[key] = value;
|
||||
},
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
applyModernValue(
|
||||
context,
|
||||
media,
|
||||
'fallbackDuration',
|
||||
'ankiConnect.media.fallbackDuration',
|
||||
asPositiveNumber,
|
||||
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
|
||||
(value) => {
|
||||
context.resolved.ankiConnect.media.fallbackDuration = value;
|
||||
},
|
||||
'Expected positive number.',
|
||||
);
|
||||
applyModernValue(
|
||||
context,
|
||||
media,
|
||||
'maxMediaDuration',
|
||||
'ankiConnect.media.maxMediaDuration',
|
||||
asNonNegativeNumber,
|
||||
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||
(value) => {
|
||||
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
||||
},
|
||||
'Expected non-negative number.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||
};
|
||||
|
||||
@@ -694,6 +695,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.ai.enabled' ||
|
||||
path === 'ankiConnect.media.normalizeAudio' ||
|
||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||
path === 'ankiConnect.media.reviewTiming' ||
|
||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
} from '../../types';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
} from '../../types/anki';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
@@ -98,6 +103,13 @@ export interface IpcServiceDeps {
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
previewMediaTimingReview?: (
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
) => Promise<MediaTimingReviewActionResult>;
|
||||
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||
resolveMediaTimingReview?: (
|
||||
request: MediaTimingReviewResolveRequest,
|
||||
) => MediaTimingReviewActionResult | Promise<MediaTimingReviewActionResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -221,6 +233,59 @@ 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 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 === '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)
|
||||
) {
|
||||
return {
|
||||
reviewId: record.reviewId,
|
||||
decision: {
|
||||
action: 'confirm',
|
||||
startTime: decisionRecord.startTime,
|
||||
endTime: decisionRecord.endTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -277,6 +342,9 @@ export interface IpcDepsRuntimeOptions {
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
||||
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
||||
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -370,6 +438,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
||||
runSubsyncManual: options.runSubsyncManual,
|
||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||
previewMediaTimingReview: options.previewMediaTimingReview,
|
||||
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
||||
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||
getRuntimeOptions: options.getRuntimeOptions,
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
@@ -491,6 +562,36 @@ 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.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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import net from 'node:net';
|
||||
import { describe, test } from 'node:test';
|
||||
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
|
||||
|
||||
describe('buildMediaTimingPreviewArgs', () => {
|
||||
test('creates a hidden audio-only reusable mpv session', () => {
|
||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
||||
mediaPath: '/video/show.mkv',
|
||||
audioTrackId: 3,
|
||||
volume: 55,
|
||||
});
|
||||
|
||||
assert.ok(args.includes('--no-video'));
|
||||
assert.ok(args.includes('--force-window=no'));
|
||||
assert.ok(args.includes('--idle=yes'));
|
||||
assert.ok(args.includes('--pause=yes'));
|
||||
assert.ok(args.includes('--input-ipc-server=/tmp/review.sock'));
|
||||
assert.ok(args.includes('--aid=3'));
|
||||
assert.ok(args.includes('--volume=55'));
|
||||
assert.equal(args.at(-2), '--');
|
||||
assert.equal(args.at(-1), '/video/show.mkv');
|
||||
});
|
||||
});
|
||||
|
||||
test('preview session handles socket errors after connecting', async () => {
|
||||
const socket = new net.Socket();
|
||||
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;
|
||||
},
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
|
||||
await session.start({ mediaPath: '/video/show.mkv' });
|
||||
assert.doesNotThrow(() => socket.emit('error', new Error('pipe closed')));
|
||||
await assert.rejects(session.play(1, 2), /not ready/);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
test('preview session rejects a connection that finishes after disposal', async () => {
|
||||
const socket = new net.Socket();
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
child.kill = () => true;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
platform: 'linux',
|
||||
spawnProcess: () => child as never,
|
||||
connectSocket: () => socket,
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
|
||||
const pendingStart = session.start({ mediaPath: '-playlist' });
|
||||
session.dispose();
|
||||
socket.emit('connect');
|
||||
|
||||
await assert.rejects(pendingStart, /closed/);
|
||||
assert.equal(socket.destroyed, true);
|
||||
});
|
||||
|
||||
test('preview session shares one startup across concurrent start calls', async () => {
|
||||
const socket = new net.Socket();
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
child.kill = () => true;
|
||||
let spawnCount = 0;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
platform: 'linux',
|
||||
spawnProcess: () => {
|
||||
spawnCount += 1;
|
||||
return child as never;
|
||||
},
|
||||
connectSocket: () => socket,
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
|
||||
const firstStart = session.start({ mediaPath: '/video/show.mkv' });
|
||||
const secondStart = session.start({ mediaPath: '/video/show.mkv' });
|
||||
socket.emit('connect');
|
||||
|
||||
await Promise.all([firstStart, secondStart]);
|
||||
assert.equal(spawnCount, 1);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
test('preview session can start again after a startup failure', async () => {
|
||||
const socket = new net.Socket();
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
child.kill = () => true;
|
||||
let spawnCount = 0;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
platform: 'linux',
|
||||
spawnProcess: () => {
|
||||
spawnCount += 1;
|
||||
if (spawnCount === 1) throw new Error('spawn failed');
|
||||
return child as never;
|
||||
},
|
||||
connectSocket: () => {
|
||||
queueMicrotask(() => socket.emit('connect'));
|
||||
return socket;
|
||||
},
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
|
||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /spawn failed/);
|
||||
await session.start({ mediaPath: '/video/show.mkv' });
|
||||
assert.equal(spawnCount, 2);
|
||||
session.dispose();
|
||||
});
|
||||
|
||||
test('preview session bounds a connection attempt that never settles', async () => {
|
||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
||||
child.kill = () => true;
|
||||
let nowMs = 0;
|
||||
let connectAttempts = 0;
|
||||
const session = new MediaTimingPreviewSession({
|
||||
platform: 'linux',
|
||||
spawnProcess: () => child as never,
|
||||
connectSocket: () => {
|
||||
connectAttempts += 1;
|
||||
return new net.Socket();
|
||||
},
|
||||
now: () => {
|
||||
const current = nowMs;
|
||||
nowMs += 1_000;
|
||||
return current;
|
||||
},
|
||||
schedule: (callback) => setTimeout(callback, 0),
|
||||
cancelSchedule: (timeout) => clearTimeout(timeout),
|
||||
removeSocketFile: () => undefined,
|
||||
createSocketPath: () => '/tmp/review.sock',
|
||||
});
|
||||
|
||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
||||
assert.equal(connectAttempts, 1);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import net, { type Socket } from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5_000;
|
||||
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
|
||||
const CONNECT_RETRY_MS = 40;
|
||||
|
||||
export interface MediaTimingPreviewStartOptions {
|
||||
mediaPath: string;
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
|
||||
|
||||
interface MediaTimingPreviewDeps {
|
||||
platform: NodeJS.Platform;
|
||||
spawnProcess: (command: string, args: string[]) => PreviewProcess;
|
||||
connectSocket: (socketPath: string) => Socket;
|
||||
now: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
cancelSchedule: (timeout: ReturnType<typeof setTimeout>) => void;
|
||||
removeSocketFile: (socketPath: string) => void;
|
||||
createSocketPath: () => string;
|
||||
}
|
||||
|
||||
export function buildMediaTimingPreviewArgs(
|
||||
socketPath: string,
|
||||
options: MediaTimingPreviewStartOptions,
|
||||
): string[] {
|
||||
const args = [
|
||||
'--no-config',
|
||||
'--no-video',
|
||||
'--audio-display=no',
|
||||
'--force-window=no',
|
||||
'--idle=yes',
|
||||
'--keep-open=yes',
|
||||
'--pause=yes',
|
||||
'--terminal=no',
|
||||
'--msg-level=all=warn',
|
||||
`--input-ipc-server=${socketPath}`,
|
||||
];
|
||||
if (typeof options.audioTrackId === 'number' && Number.isInteger(options.audioTrackId)) {
|
||||
args.push(`--aid=${options.audioTrackId}`);
|
||||
}
|
||||
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
|
||||
args.push(`--volume=${Math.max(0, options.volume)}`);
|
||||
}
|
||||
args.push('--', options.mediaPath);
|
||||
return args;
|
||||
}
|
||||
|
||||
function createDefaultSocketPath(): string {
|
||||
const suffix = `${process.pid}-${randomUUID()}`;
|
||||
return process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\subminer-timing-preview-${suffix}`
|
||||
: path.join(os.tmpdir(), `subminer-timing-preview-${suffix}.sock`);
|
||||
}
|
||||
|
||||
function removePosixSocketFile(socketPath: string): void {
|
||||
if (process.platform === 'win32') return;
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaTimingPreviewSession {
|
||||
private readonly deps: MediaTimingPreviewDeps;
|
||||
private socketPath: string | null = null;
|
||||
private socket: Socket | null = null;
|
||||
private process: PreviewProcess | null = null;
|
||||
private startupError: Error | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private retryWait: {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
resolve: () => void;
|
||||
} | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
|
||||
this.deps = {
|
||||
platform: process.platform,
|
||||
spawnProcess: (command, args) => spawn(command, args, { stdio: 'ignore' }),
|
||||
connectSocket: (socketPath) => net.createConnection(socketPath),
|
||||
now: Date.now,
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
cancelSchedule: (timeout) => clearTimeout(timeout),
|
||||
removeSocketFile: removePosixSocketFile,
|
||||
createSocketPath: createDefaultSocketPath,
|
||||
...deps,
|
||||
};
|
||||
}
|
||||
|
||||
async start(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||
if (this.disposed) throw new Error('Preview session is closed');
|
||||
if (this.socket) return;
|
||||
if (this.startPromise) return await this.startPromise;
|
||||
|
||||
const startPromise = this.startOnce(options);
|
||||
this.startPromise = startPromise;
|
||||
try {
|
||||
await startPromise;
|
||||
} catch (error) {
|
||||
this.releaseResources();
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.startPromise === startPromise) this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
|
||||
const mediaPath = options.mediaPath.trim();
|
||||
if (!mediaPath) throw new Error('No media source is available for preview');
|
||||
|
||||
const socketPath = this.deps.createSocketPath();
|
||||
this.socketPath = socketPath;
|
||||
if (this.deps.platform !== 'win32') {
|
||||
this.deps.removeSocketFile(socketPath);
|
||||
}
|
||||
|
||||
const command = options.executablePath?.trim() || 'mpv';
|
||||
this.startupError = null;
|
||||
const child = this.deps.spawnProcess(
|
||||
command,
|
||||
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }),
|
||||
);
|
||||
this.process = child;
|
||||
child.once('error', (error) => {
|
||||
if (this.process !== child) return;
|
||||
this.startupError = error;
|
||||
});
|
||||
child.once('exit', () => {
|
||||
if (this.process !== child) return;
|
||||
if (!this.socket && !this.disposed && !this.startupError) {
|
||||
this.startupError = new Error('The hidden mpv preview player exited during startup');
|
||||
}
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
await this.connectWithRetry(socketPath);
|
||||
}
|
||||
|
||||
async play(startTime: number, endTime: number): Promise<void> {
|
||||
if (!this.socket || this.socket.destroyed) {
|
||||
throw new Error('Preview player is not ready');
|
||||
}
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||
throw new Error('Preview timing is invalid');
|
||||
}
|
||||
|
||||
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']);
|
||||
this.send(['set_property', 'pause', false]);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.socket || this.socket.destroyed) return;
|
||||
this.send(['set_property', 'pause', true]);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.releaseResources();
|
||||
}
|
||||
|
||||
private releaseResources(): void {
|
||||
this.cancelRetryWait();
|
||||
try {
|
||||
this.send(['quit']);
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
this.socket?.end();
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
const child = this.process;
|
||||
this.process = null;
|
||||
child?.kill();
|
||||
if (this.socketPath && this.deps.platform !== 'win32') {
|
||||
try {
|
||||
this.deps.removeSocketFile(this.socketPath);
|
||||
} catch {
|
||||
// mpv may still be releasing the socket. The OS temp directory owns cleanup.
|
||||
}
|
||||
}
|
||||
this.socketPath = null;
|
||||
}
|
||||
|
||||
private send(command: Array<string | number | boolean>): void {
|
||||
if (!this.socket || this.socket.destroyed) {
|
||||
throw new Error('Preview player is not connected');
|
||||
}
|
||||
this.socket.write(`${JSON.stringify({ command })}\n`);
|
||||
}
|
||||
|
||||
private async connectWithRetry(socketPath: string): Promise<void> {
|
||||
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
|
||||
while (!this.disposed && this.deps.now() < deadline) {
|
||||
if (this.startupError) {
|
||||
throw this.startupError;
|
||||
}
|
||||
try {
|
||||
const remainingMs = deadline - this.deps.now();
|
||||
if (remainingMs <= 0) break;
|
||||
const socket = await this.connectOnce(
|
||||
socketPath,
|
||||
Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
|
||||
);
|
||||
if (this.disposed) {
|
||||
socket.destroy();
|
||||
throw new Error('Preview session is closed');
|
||||
}
|
||||
this.socket = socket;
|
||||
return;
|
||||
} catch {
|
||||
if (this.disposed) {
|
||||
throw new Error('Preview session is closed');
|
||||
}
|
||||
const remainingMs = deadline - this.deps.now();
|
||||
if (remainingMs <= 0) break;
|
||||
await this.waitForRetry(Math.min(CONNECT_RETRY_MS, remainingMs));
|
||||
}
|
||||
}
|
||||
if (this.startupError) {
|
||||
throw this.startupError;
|
||||
}
|
||||
if (this.disposed) {
|
||||
throw new Error('Preview session is closed');
|
||||
}
|
||||
throw new Error('Timed out starting the hidden mpv preview player');
|
||||
}
|
||||
|
||||
private waitForRetry(delayMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = this.deps.schedule(() => {
|
||||
if (this.retryWait?.timeout === timeout) this.retryWait = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
this.retryWait = { timeout, resolve };
|
||||
});
|
||||
}
|
||||
|
||||
private cancelRetryWait(): void {
|
||||
const pending = this.retryWait;
|
||||
this.retryWait = null;
|
||||
if (!pending) return;
|
||||
this.deps.cancelSchedule(pending.timeout);
|
||||
pending.resolve();
|
||||
}
|
||||
|
||||
private connectOnce(socketPath: string, timeoutMs: number): Promise<Socket> {
|
||||
return new Promise<Socket>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let settled = false;
|
||||
const clearAttemptTimeout = (): void => {
|
||||
if (timeout !== null) this.deps.cancelSchedule(timeout);
|
||||
timeout = null;
|
||||
};
|
||||
const socket = this.deps.connectSocket(socketPath);
|
||||
const onConnect = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAttemptTimeout();
|
||||
socket.off('error', onError);
|
||||
socket.on('error', () => {
|
||||
socket.destroy();
|
||||
if (this.socket === socket) this.socket = null;
|
||||
});
|
||||
socket.once('close', () => {
|
||||
if (this.socket === socket) this.socket = null;
|
||||
});
|
||||
resolve(socket);
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAttemptTimeout();
|
||||
socket.off('connect', onConnect);
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
socket.once('connect', onConnect);
|
||||
socket.once('error', onError);
|
||||
timeout = this.deps.schedule(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
timeout = null;
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('error', onError);
|
||||
socket.destroy();
|
||||
reject(new Error('Timed out connecting to the hidden mpv preview player'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
@@ -462,6 +462,9 @@ import { createMainBootServices, type MainBootServicesResult } from './main/boot
|
||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
||||
import { createMediaTimingReviewRuntime } from './main/runtime/media-timing-review';
|
||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||
@@ -2797,6 +2800,18 @@ function createOverlayHostedModalOpenDeps(): {
|
||||
};
|
||||
}
|
||||
|
||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentMediaPath: () =>
|
||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||
getMpvExecutablePath: () =>
|
||||
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
||||
showStatus: (message) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
||||
});
|
||||
|
||||
function openOverlayHostedModalWithOsd(
|
||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||
unavailableMessage: string,
|
||||
@@ -5014,6 +5029,7 @@ function initializeOverlayRuntime(): void {
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
@@ -5423,6 +5439,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||
},
|
||||
mainDeps: {
|
||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
||||
focusMainWindow: () => {
|
||||
@@ -5456,6 +5475,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal, senderWindow) => {
|
||||
if (modal === 'media-timing-review') {
|
||||
void mediaTimingReviewRuntime.dispose();
|
||||
}
|
||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||
subtitleSidebarRequestedOpen = false;
|
||||
}
|
||||
@@ -5811,6 +5833,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||
consumePendingSubtitleMiningContext,
|
||||
);
|
||||
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
||||
mediaTimingReviewRuntime.requestReview,
|
||||
);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||
@@ -6133,6 +6158,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
if (overlayManager.getModalWindow() !== window) {
|
||||
return;
|
||||
}
|
||||
void mediaTimingReviewRuntime.dispose();
|
||||
overlayManager.setModalWindow(null);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
||||
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
||||
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||
@@ -256,6 +259,9 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
previewMediaTimingReview: params.previewMediaTimingReview,
|
||||
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
||||
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const MODAL: OverlayHostedModal = 'media-timing-review';
|
||||
|
||||
export async function openMediaTimingReviewModal(
|
||||
deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
payload: MediaTimingReviewOpenPayload,
|
||||
): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
||||
{
|
||||
modal: MODAL,
|
||||
timeoutMs: 1_500,
|
||||
retryWarning:
|
||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: 'media-timing-review:open',
|
||||
modal: MODAL,
|
||||
payload,
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import {
|
||||
buildMediaTimingReviewPayload,
|
||||
createMediaTimingReviewRuntime,
|
||||
} from './media-timing-review';
|
||||
|
||||
describe('buildMediaTimingReviewPayload', () => {
|
||||
test('starts from the padded range and leaves two seconds to drag on each side', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
{ reviewId: 'review-1', mediaDuration: 100 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 9.5);
|
||||
assert.equal(payload.selectionEndTime, 12.5);
|
||||
assert.equal(payload.timelineStartTime, 7.5);
|
||||
assert.equal(payload.timelineEndTime, 14.5);
|
||||
});
|
||||
|
||||
test('clamps the padded selection and timeline to media bounds', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 0.2,
|
||||
endTime: 9.8,
|
||||
audioPadding: 1,
|
||||
maxMediaDuration: 30,
|
||||
},
|
||||
{ reviewId: 'review-2', mediaDuration: 10 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 0);
|
||||
assert.equal(payload.selectionEndTime, 10);
|
||||
assert.equal(payload.timelineStartTime, 0);
|
||||
assert.equal(payload.timelineEndTime, 10);
|
||||
});
|
||||
|
||||
test('keeps an uncapped selection when max media duration is disabled', () => {
|
||||
const payload = buildMediaTimingReviewPayload(
|
||||
{
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 55,
|
||||
audioPadding: 1,
|
||||
maxMediaDuration: 0,
|
||||
},
|
||||
{ reviewId: 'review-unlimited', mediaDuration: 100 },
|
||||
);
|
||||
|
||||
assert.equal(payload.selectionStartTime, 9);
|
||||
assert.equal(payload.selectionEndTime, 56);
|
||||
assert.equal(payload.maxMediaDuration, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const previewCalls: Array<[number, number]> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) =>
|
||||
({ pause: false, duration: 100, aid: 2, volume: 60 })[
|
||||
name as 'pause' | 'duration' | 'aid' | 'volume'
|
||||
],
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async (startTime, endTime) => {
|
||||
previewCalls.push([startTime, endTime]);
|
||||
},
|
||||
stop: async () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime
|
||||
.previewRange({
|
||||
reviewId: payload.reviewId,
|
||||
startTime: 9.5,
|
||||
endTime: 12.5,
|
||||
})
|
||||
.then(() => {
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
||||
});
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
const decision = await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
noteId: 42,
|
||||
audioPadding: 0.5,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
|
||||
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
assert.deepEqual(previewCalls, [[9.5, 12.5]]);
|
||||
});
|
||||
|
||||
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
||||
runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async () => null,
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => '',
|
||||
createPreviewSession: () => ({
|
||||
start: async () => {
|
||||
throw new Error('preview unavailable');
|
||||
},
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
runtime.resolveReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision: { action: 'use-original' },
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.requestReview({
|
||||
kind: 'sentence',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ action: 'use-original' },
|
||||
);
|
||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
test('media timing review restores playback when setup fails after pausing', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
createPreviewSession: () => {
|
||||
throw new Error('preview setup failed');
|
||||
},
|
||||
openModal: async () => true,
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ action: 'use-original' },
|
||||
);
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('disposing an open review settles it with original timing and restores playback', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const runtime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: '/video/show.mkv',
|
||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
||||
send: ({ command }) => commands.push(command),
|
||||
}),
|
||||
getCurrentMediaPath: () => '/video/show.mkv',
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
createPreviewSession: () => ({
|
||||
start: async () => undefined,
|
||||
play: async () => undefined,
|
||||
stop: async () => undefined,
|
||||
dispose: () => undefined,
|
||||
}),
|
||||
openModal: async () => true,
|
||||
showStatus: () => undefined,
|
||||
});
|
||||
|
||||
const pending = runtime.requestReview({
|
||||
kind: 'word',
|
||||
text: '字幕',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
audioPadding: 0,
|
||||
maxMediaDuration: 30,
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await runtime.dispose();
|
||||
|
||||
assert.deepEqual(await pending, { action: 'use-original' });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
} from '../../types/anki';
|
||||
|
||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
||||
|
||||
interface ReviewMpvClient {
|
||||
connected: boolean;
|
||||
currentVideoPath: string;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
send: (payload: { command: Array<string | number> }) => void;
|
||||
}
|
||||
|
||||
interface PreviewSession {
|
||||
start(options: {
|
||||
mediaPath: string;
|
||||
executablePath?: string;
|
||||
audioTrackId?: number;
|
||||
volume?: number;
|
||||
}): Promise<void>;
|
||||
play(startTime: number, endTime: number): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ActiveReview {
|
||||
payload: MediaTimingReviewOpenPayload;
|
||||
mpvClient: ReviewMpvClient;
|
||||
restorePlayback: boolean;
|
||||
preview: Promise<PreviewSession>;
|
||||
resolve: (decision: MediaTimingReviewDecision) => void;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewRuntimeDeps {
|
||||
getMpvClient: () => ReviewMpvClient | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getMpvExecutablePath: () => string;
|
||||
createPreviewSession: () => PreviewSession;
|
||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
||||
showStatus: (message: string) => void;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function booleanProperty(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'yes' || value === 1) return true;
|
||||
if (value === 'no' || value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildMediaTimingReviewPayload(
|
||||
request: MediaTimingReviewRequest,
|
||||
options: { reviewId: string; mediaDuration?: number },
|
||||
): MediaTimingReviewOpenPayload {
|
||||
const duration = finiteNumber(options.mediaDuration);
|
||||
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||||
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
|
||||
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
|
||||
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
|
||||
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
|
||||
paddedEnd = paddedStart + maxMediaDuration;
|
||||
}
|
||||
if (paddedEnd <= paddedStart) {
|
||||
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
|
||||
}
|
||||
|
||||
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
|
||||
const timelineEndTime = Math.max(
|
||||
paddedEnd,
|
||||
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
|
||||
);
|
||||
|
||||
return {
|
||||
reviewId: options.reviewId,
|
||||
kind: request.kind,
|
||||
text: request.text,
|
||||
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
|
||||
originalStartTime: request.startTime,
|
||||
originalEndTime: request.endTime,
|
||||
selectionStartTime: paddedStart,
|
||||
selectionEndTime: paddedEnd,
|
||||
timelineStartTime,
|
||||
timelineEndTime,
|
||||
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
|
||||
maxMediaDuration,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
|
||||
let active: ActiveReview | null = null;
|
||||
let reviewInProgress = false;
|
||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
||||
|
||||
function restorePendingPlayback(): void {
|
||||
const mpvClient = pendingPauseRestore;
|
||||
pendingPauseRestore = null;
|
||||
if (mpvClient?.connected) {
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const mediaPath =
|
||||
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
|
||||
if (!mpvClient?.connected || !mediaPath) {
|
||||
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw] = await Promise.all([
|
||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
||||
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
||||
]);
|
||||
const pauseState = booleanProperty(pauseRaw);
|
||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
||||
|
||||
const payload = buildMediaTimingReviewPayload(request, {
|
||||
reviewId: randomUUID(),
|
||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
||||
});
|
||||
const previewSession = deps.createPreviewSession();
|
||||
const preview = previewSession
|
||||
.start({
|
||||
mediaPath,
|
||||
executablePath: deps.getMpvExecutablePath(),
|
||||
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
||||
volume: finiteNumber(volumeRaw) ?? undefined,
|
||||
})
|
||||
.then(() => previewSession)
|
||||
.catch((error) => {
|
||||
previewSession.dispose();
|
||||
throw error;
|
||||
});
|
||||
void preview.catch(() => {});
|
||||
|
||||
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
|
||||
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
|
||||
resolveDecision = resolve;
|
||||
});
|
||||
active = {
|
||||
payload,
|
||||
mpvClient,
|
||||
restorePlayback: pendingPauseRestore === mpvClient,
|
||||
preview,
|
||||
resolve: resolveDecision,
|
||||
};
|
||||
pendingPauseRestore = null;
|
||||
|
||||
const opened = await deps.openModal(payload).catch(() => false);
|
||||
if (!opened) {
|
||||
await cleanupActiveReview();
|
||||
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
|
||||
const decision = await decisionPromise;
|
||||
await cleanupActiveReview();
|
||||
return decision;
|
||||
}
|
||||
|
||||
async function requestReview(
|
||||
request: MediaTimingReviewRequest,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (active || reviewInProgress) {
|
||||
deps.showStatus('Finish the current timing review before mining another card.');
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
reviewInProgress = true;
|
||||
try {
|
||||
return await runReview(request);
|
||||
} catch {
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
deps.showStatus('Timing review failed. Using the original subtitle timing.');
|
||||
return { action: 'use-original' };
|
||||
} finally {
|
||||
reviewInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewRange(
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
if (
|
||||
!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.' };
|
||||
}
|
||||
try {
|
||||
const previewSession = await current.preview;
|
||||
await previewSession.play(request.startTime, request.endTime);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
||||
const current = active;
|
||||
if (!current || reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
try {
|
||||
const previewSession = await current.preview;
|
||||
await previewSession.stop();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
|
||||
const current = active;
|
||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
||||
return { ok: false, message: 'This timing review is no longer active.' };
|
||||
}
|
||||
if (request.decision.action === 'confirm') {
|
||||
const { startTime, endTime } = request.decision;
|
||||
if (
|
||||
!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.' };
|
||||
}
|
||||
}
|
||||
current.resolve(request.decision);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function cleanupActiveReview(): Promise<void> {
|
||||
const current = active;
|
||||
active = null;
|
||||
if (!current) return;
|
||||
void current.preview.then((session) => session.dispose()).catch(() => {});
|
||||
if (current.restorePlayback && current.mpvClient.connected) {
|
||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
||||
}
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
active?.resolve({ action: 'use-original' });
|
||||
await cleanupActiveReview();
|
||||
restorePendingPlayback();
|
||||
}
|
||||
|
||||
return {
|
||||
requestReview,
|
||||
previewRange,
|
||||
stopPreview,
|
||||
resolveReview,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,9 @@ import type {
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
ChangelogSnapshot,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -181,6 +184,11 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
||||
IPC_CHANNELS.event.youtubePickerOpen,
|
||||
(payload) => payload as YoutubePickerOpenPayload,
|
||||
);
|
||||
const onOpenMediaTimingReviewEvent =
|
||||
createQueuedIpcListenerWithPayload<MediaTimingReviewOpenPayload>(
|
||||
IPC_CHANNELS.event.mediaTimingReviewOpen,
|
||||
(payload) => payload as MediaTimingReviewOpenPayload,
|
||||
);
|
||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
@@ -458,6 +466,13 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
||||
previewMediaTimingReview: (request: MediaTimingReviewPreviewRequest) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
||||
stopMediaTimingReviewPreview: (reviewId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
||||
resolveMediaTimingReview: (request: MediaTimingReviewResolveRequest) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewResolve, request),
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createKeyboardHandlers(
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleMediaTimingReviewKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -1078,6 +1079,11 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||
options.handleMediaTimingReviewKeydown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||
e.preventDefault();
|
||||
handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -196,6 +196,195 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="mediaTimingReviewModal"
|
||||
class="modal media-timing-review-modal hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="modal-content media-timing-review-content"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mediaTimingReviewTitle"
|
||||
>
|
||||
<div class="media-timing-review-header">
|
||||
<div>
|
||||
<div id="mediaTimingReviewKind" class="media-timing-review-eyebrow">
|
||||
Sentence card
|
||||
</div>
|
||||
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
||||
Review media timing
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="mediaTimingReviewCancel"
|
||||
class="media-timing-review-quiet-button"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
|
||||
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
|
||||
|
||||
<div class="media-timing-review-readout" aria-live="polite">
|
||||
<div>
|
||||
<span>Starts</span>
|
||||
<strong id="mediaTimingReviewStartValue">00:00.000</strong>
|
||||
</div>
|
||||
<div class="media-timing-review-duration">
|
||||
<span>Clip length</span>
|
||||
<strong id="mediaTimingReviewDuration">0.00s</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Ends</span>
|
||||
<strong id="mediaTimingReviewEndValue">00:00.000</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-timing-review-timeline-shell">
|
||||
<div class="media-timing-review-timeline-labels">
|
||||
<span id="mediaTimingReviewTimelineStart">00:00</span>
|
||||
<span>available media</span>
|
||||
<span id="mediaTimingReviewTimelineEnd">00:00</span>
|
||||
</div>
|
||||
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
|
||||
<div class="media-timing-review-selected-range"></div>
|
||||
<input
|
||||
id="mediaTimingReviewStartRange"
|
||||
class="media-timing-review-range media-timing-review-range-start"
|
||||
type="range"
|
||||
step="0.01"
|
||||
aria-label="Clip start"
|
||||
/>
|
||||
<input
|
||||
id="mediaTimingReviewEndRange"
|
||||
class="media-timing-review-range media-timing-review-range-end"
|
||||
type="range"
|
||||
step="0.01"
|
||||
aria-label="Clip end"
|
||||
/>
|
||||
</div>
|
||||
<div class="media-timing-review-expand-row">
|
||||
<button
|
||||
id="mediaTimingReviewShowEarlier"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
>
|
||||
Show earlier
|
||||
</button>
|
||||
<span>Drag either edge to set the exact clip.</span>
|
||||
<button
|
||||
id="mediaTimingReviewShowLater"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
>
|
||||
Show later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-timing-review-fine-grid">
|
||||
<div class="media-timing-review-fine-control">
|
||||
<span>Start</span>
|
||||
<button
|
||||
id="mediaTimingReviewStartBack"
|
||||
type="button"
|
||||
aria-label="Move start backward 100 milliseconds"
|
||||
>
|
||||
− 0.1s
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewStartForward"
|
||||
type="button"
|
||||
aria-label="Move start forward 100 milliseconds"
|
||||
>
|
||||
+ 0.1s
|
||||
</button>
|
||||
</div>
|
||||
<div class="media-timing-review-fine-control">
|
||||
<span>End</span>
|
||||
<button
|
||||
id="mediaTimingReviewEndBack"
|
||||
type="button"
|
||||
aria-label="Move end backward 100 milliseconds"
|
||||
>
|
||||
− 0.1s
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewEndForward"
|
||||
type="button"
|
||||
aria-label="Move end forward 100 milliseconds"
|
||||
>
|
||||
+ 0.1s
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mediaTimingReviewStatus"
|
||||
class="media-timing-review-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="media-timing-review-footer">
|
||||
<div class="media-timing-review-preview-actions">
|
||||
<button
|
||||
id="mediaTimingReviewPlay"
|
||||
class="media-timing-review-play-button"
|
||||
type="button"
|
||||
>
|
||||
Play selection
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewReset"
|
||||
class="media-timing-review-quiet-button"
|
||||
type="button"
|
||||
>
|
||||
Reset timing
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="mediaTimingReviewConfirm"
|
||||
class="media-timing-review-confirm-button"
|
||||
type="button"
|
||||
>
|
||||
Use this timing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
||||
<div class="media-timing-review-cancel-mark" aria-hidden="true">?</div>
|
||||
<h2>Stop reviewing?</h2>
|
||||
<p id="mediaTimingReviewCancelMessage">Choose what should happen to this card.</p>
|
||||
<div class="media-timing-review-cancel-actions">
|
||||
<button
|
||||
id="mediaTimingReviewCancelBack"
|
||||
class="media-timing-review-quiet-button"
|
||||
type="button"
|
||||
>
|
||||
Keep editing
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewUseOriginal"
|
||||
class="media-timing-review-original-button"
|
||||
type="button"
|
||||
>
|
||||
Use original timing
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewDiscard"
|
||||
class="media-timing-review-discard-button"
|
||||
type="button"
|
||||
>
|
||||
Delete card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
constrainMediaTimingSelection,
|
||||
createMediaTimingPreviewRequestGuard,
|
||||
formatMediaTimingTimestamp,
|
||||
} from './media-timing-review';
|
||||
|
||||
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
||||
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
||||
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
|
||||
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
|
||||
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
|
||||
});
|
||||
|
||||
test('selection constraints preserve the handle that did not move', () => {
|
||||
assert.deepEqual(
|
||||
constrainMediaTimingSelection({
|
||||
nextStart: 3,
|
||||
nextEnd: 2,
|
||||
currentStart: 1,
|
||||
timelineStart: 0,
|
||||
timelineEnd: 10,
|
||||
mediaEnd: 10,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ start: 1.9, end: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
constrainMediaTimingSelection({
|
||||
nextStart: 1,
|
||||
nextEnd: 0.5,
|
||||
currentStart: 1,
|
||||
timelineStart: 0,
|
||||
timelineEnd: 10,
|
||||
mediaEnd: 10,
|
||||
maxMediaDuration: 30,
|
||||
}),
|
||||
{ start: 1, end: 1.1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
||||
const guard = createMediaTimingPreviewRequestGuard();
|
||||
const first = guard.begin();
|
||||
|
||||
assert.equal(typeof first, 'number');
|
||||
assert.equal(guard.begin(), null);
|
||||
assert.equal(guard.isCurrent(first!), true);
|
||||
|
||||
guard.invalidate();
|
||||
assert.equal(guard.isCurrent(first!), false);
|
||||
assert.equal(guard.isInFlight(), false);
|
||||
|
||||
const second = guard.begin();
|
||||
assert.equal(typeof second, 'number');
|
||||
guard.finish(first!);
|
||||
assert.equal(guard.isCurrent(second!), true);
|
||||
guard.finish(second!);
|
||||
assert.equal(guard.isInFlight(), false);
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
|
||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||
const FINE_ADJUST_SECONDS = 0.1;
|
||||
const TIMELINE_EXPANSION_SECONDS = 5;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds = true): string {
|
||||
const unitsPerSecond = includeMilliseconds ? 1_000 : 1;
|
||||
const totalUnits = Math.max(0, Math.round(seconds * unitsPerSecond));
|
||||
const unitsPerMinute = 60 * unitsPerSecond;
|
||||
const minutes = Math.floor(totalUnits / unitsPerMinute);
|
||||
const remainingUnits = totalUnits % unitsPerMinute;
|
||||
const remaining = includeMilliseconds
|
||||
? (remainingUnits / unitsPerSecond).toFixed(3)
|
||||
: String(remainingUnits);
|
||||
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
|
||||
}
|
||||
|
||||
export function createMediaTimingPreviewRequestGuard() {
|
||||
let sequence = 0;
|
||||
let activeRequestId: number | null = null;
|
||||
|
||||
return {
|
||||
begin(): number | null {
|
||||
if (activeRequestId !== null) return null;
|
||||
sequence += 1;
|
||||
activeRequestId = sequence;
|
||||
return activeRequestId;
|
||||
},
|
||||
invalidate(): void {
|
||||
sequence += 1;
|
||||
activeRequestId = null;
|
||||
},
|
||||
isCurrent(requestId: number): boolean {
|
||||
return requestId === activeRequestId;
|
||||
},
|
||||
finish(requestId: number): void {
|
||||
if (activeRequestId === requestId) activeRequestId = null;
|
||||
},
|
||||
isInFlight(): boolean {
|
||||
return activeRequestId !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function constrainMediaTimingSelection(options: {
|
||||
nextStart: number;
|
||||
nextEnd: number;
|
||||
currentStart: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
mediaEnd: number;
|
||||
maxMediaDuration: number;
|
||||
}): { start: number; end: number } {
|
||||
let start = clamp(options.nextStart, options.timelineStart, options.timelineEnd);
|
||||
let end = clamp(options.nextEnd, options.timelineStart, options.timelineEnd);
|
||||
const startMoved = options.nextStart !== options.currentStart;
|
||||
if (end - start < MINIMUM_CLIP_SECONDS) {
|
||||
if (startMoved) start = end - MINIMUM_CLIP_SECONDS;
|
||||
else end = start + MINIMUM_CLIP_SECONDS;
|
||||
}
|
||||
if (options.maxMediaDuration > 0 && end - start > options.maxMediaDuration) {
|
||||
if (startMoved) start = end - options.maxMediaDuration;
|
||||
else end = start + options.maxMediaDuration;
|
||||
}
|
||||
return {
|
||||
start: Math.max(0, start),
|
||||
end: Math.min(options.mediaEnd, end),
|
||||
};
|
||||
}
|
||||
|
||||
export function createMediaTimingReviewModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let payload: MediaTimingReviewOpenPayload | null = null;
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
let timelineStart = 0;
|
||||
let timelineEnd = 0;
|
||||
let resolveInFlight = false;
|
||||
let previewPlaying = false;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const previewRequest = createMediaTimingPreviewRequestGuard();
|
||||
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.dom.mediaTimingReviewStatus.textContent = message;
|
||||
ctx.dom.mediaTimingReviewStatus.classList.toggle('is-error', isError);
|
||||
}
|
||||
|
||||
function clearPreviewTimer(): void {
|
||||
if (previewTimer !== null) clearTimeout(previewTimer);
|
||||
previewTimer = null;
|
||||
}
|
||||
|
||||
function setPreviewPlaying(playing: boolean): void {
|
||||
previewPlaying = playing;
|
||||
ctx.dom.mediaTimingReviewPlay.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||
clearPreviewTimer();
|
||||
if (playing) {
|
||||
previewTimer = setTimeout(() => stopPreview(), (selectionEnd - selectionStart) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPreview(): void {
|
||||
previewRequest.invalidate();
|
||||
setPreviewPlaying(false);
|
||||
if (payload) {
|
||||
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelection(): void {
|
||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
||||
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--selection-start',
|
||||
`${clamp(startPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--selection-end',
|
||||
`${clamp(endPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewStartRange.min = String(timelineStart);
|
||||
ctx.dom.mediaTimingReviewStartRange.max = String(timelineEnd);
|
||||
ctx.dom.mediaTimingReviewStartRange.value = String(selectionStart);
|
||||
ctx.dom.mediaTimingReviewEndRange.min = String(timelineStart);
|
||||
ctx.dom.mediaTimingReviewEndRange.max = String(timelineEnd);
|
||||
ctx.dom.mediaTimingReviewEndRange.value = String(selectionEnd);
|
||||
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
||||
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
||||
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
||||
ctx.dom.mediaTimingReviewTimelineStart.textContent = formatMediaTimingTimestamp(
|
||||
timelineStart,
|
||||
false,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewTimelineEnd.textContent = formatMediaTimingTimestamp(
|
||||
timelineEnd,
|
||||
false,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewShowEarlier.disabled = timelineStart <= 0;
|
||||
ctx.dom.mediaTimingReviewShowLater.disabled =
|
||||
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
|
||||
}
|
||||
|
||||
function updateSelection(nextStart: number, nextEnd: number): void {
|
||||
if (!payload) return;
|
||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
||||
const nextSelection = constrainMediaTimingSelection({
|
||||
nextStart,
|
||||
nextEnd,
|
||||
currentStart: selectionStart,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
mediaEnd,
|
||||
maxMediaDuration: payload.maxMediaDuration,
|
||||
});
|
||||
selectionStart = nextSelection.start;
|
||||
selectionEnd = nextSelection.end;
|
||||
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
|
||||
setStatus('');
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function showEditor(): void {
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
||||
ctx.dom.mediaTimingReviewCancel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function requestCancel(): void {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen || !payload || resolveInFlight) return;
|
||||
stopPreview();
|
||||
ctx.dom.mediaTimingReviewEditor.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewCancel.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
|
||||
ctx.dom.mediaTimingReviewCancelMessage.textContent =
|
||||
payload.noteId !== undefined
|
||||
? 'Keep editing, finish this card with its original timing, or delete the card.'
|
||||
: 'Keep editing, finish this card with its original timing, or do not create it.';
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
ctx.dom.mediaTimingReviewCancelBack.focus();
|
||||
}
|
||||
|
||||
function closeResolvedReview(): void {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
||||
clearPreviewTimer();
|
||||
previewPlaying = false;
|
||||
ctx.state.mediaTimingReviewModalOpen = false;
|
||||
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('media-timing-review');
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
payload = null;
|
||||
if (!options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
|
||||
if (!payload || resolveInFlight) return;
|
||||
resolveInFlight = true;
|
||||
stopPreview();
|
||||
const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
|
||||
controls.forEach((button) => {
|
||||
button.disabled = true;
|
||||
});
|
||||
try {
|
||||
const result = await window.electronAPI.resolveMediaTimingReview({
|
||||
reviewId: payload.reviewId,
|
||||
decision,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setStatus(result.message ?? 'The timing review could not be resolved.', true);
|
||||
showEditor();
|
||||
return;
|
||||
}
|
||||
closeResolvedReview();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
showEditor();
|
||||
} finally {
|
||||
resolveInFlight = false;
|
||||
controls.forEach((button) => {
|
||||
button.disabled = false;
|
||||
});
|
||||
renderSelection();
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreview(): Promise<void> {
|
||||
if (!payload || resolveInFlight) return;
|
||||
if (previewPlaying) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
const requestId = previewRequest.begin();
|
||||
if (requestId === null) return;
|
||||
const requestedReviewId = payload.reviewId;
|
||||
setStatus('Starting audio preview...');
|
||||
try {
|
||||
const result = await window.electronAPI.previewMediaTimingReview({
|
||||
reviewId: requestedReviewId,
|
||||
startTime: selectionStart,
|
||||
endTime: selectionEnd,
|
||||
});
|
||||
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) {
|
||||
if (result.ok && !previewRequest.isInFlight() && !previewPlaying) {
|
||||
void window.electronAPI.stopMediaTimingReviewPreview(requestedReviewId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
setStatus(result.message ?? 'Audio preview is unavailable.', true);
|
||||
setPreviewPlaying(false);
|
||||
return;
|
||||
}
|
||||
setStatus('Previewing in the hidden audio player.');
|
||||
setPreviewPlaying(true);
|
||||
} catch (error) {
|
||||
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) return;
|
||||
setStatus(
|
||||
`Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
true,
|
||||
);
|
||||
setPreviewPlaying(false);
|
||||
} finally {
|
||||
previewRequest.finish(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
||||
previewRequest.invalidate();
|
||||
payload = nextPayload;
|
||||
selectionStart = nextPayload.selectionStartTime;
|
||||
selectionEnd = nextPayload.selectionEndTime;
|
||||
timelineStart = nextPayload.timelineStartTime;
|
||||
timelineEnd = nextPayload.timelineEndTime;
|
||||
resolveInFlight = false;
|
||||
setPreviewPlaying(false);
|
||||
ctx.dom.mediaTimingReviewKind.textContent =
|
||||
nextPayload.kind === 'word'
|
||||
? 'Word card'
|
||||
: nextPayload.kind === 'audio'
|
||||
? 'Audio card'
|
||||
: 'Sentence card';
|
||||
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
setStatus('');
|
||||
showEditor();
|
||||
renderSelection();
|
||||
ctx.state.mediaTimingReviewModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) window.electronAPI.setIgnoreMouseEvents(false);
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
|
||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
||||
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
||||
ctx.dom.mediaTimingReviewPlay.focus();
|
||||
}
|
||||
|
||||
function expandTimeline(direction: 'earlier' | 'later'): void {
|
||||
if (!payload) return;
|
||||
if (direction === 'earlier') {
|
||||
timelineStart = Math.max(0, timelineStart - TIMELINE_EXPANSION_SECONDS);
|
||||
} else {
|
||||
timelineEnd = Math.min(
|
||||
payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
||||
timelineEnd + TIMELINE_EXPANSION_SECONDS,
|
||||
);
|
||||
}
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen) return false;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (!ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden')) showEditor();
|
||||
else requestCancel();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
event.code === 'Space' &&
|
||||
event.target instanceof Element &&
|
||||
!event.target.closest('button')
|
||||
) {
|
||||
event.preventDefault();
|
||||
void togglePreview();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
|
||||
!(event.target instanceof Element && event.target.closest('button'))
|
||||
) {
|
||||
event.preventDefault();
|
||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.mediaTimingReviewStartRange.addEventListener('input', () => {
|
||||
updateSelection(Number(ctx.dom.mediaTimingReviewStartRange.value), selectionEnd);
|
||||
});
|
||||
ctx.dom.mediaTimingReviewEndRange.addEventListener('input', () => {
|
||||
updateSelection(selectionStart, Number(ctx.dom.mediaTimingReviewEndRange.value));
|
||||
});
|
||||
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
|
||||
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
||||
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
||||
updateSelection(selectionStart - FINE_ADJUST_SECONDS, selectionEnd),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewStartForward.addEventListener('click', () =>
|
||||
updateSelection(selectionStart + FINE_ADJUST_SECONDS, selectionEnd),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewEndBack.addEventListener('click', () =>
|
||||
updateSelection(selectionStart, selectionEnd - FINE_ADJUST_SECONDS),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewEndForward.addEventListener('click', () =>
|
||||
updateSelection(selectionStart, selectionEnd + FINE_ADJUST_SECONDS),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
|
||||
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
|
||||
if (!payload) return;
|
||||
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
|
||||
});
|
||||
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
|
||||
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
|
||||
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'use-original' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewDiscard.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'discard' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewConfirm.addEventListener(
|
||||
'click',
|
||||
() =>
|
||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
openMediaTimingReviewModal,
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { createCharacterDictionaryModal } from './modals/character-dictionary.js
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||
@@ -114,6 +115,12 @@ const modalDescriptors = [
|
||||
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'media-timing-review',
|
||||
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
||||
close: () => mediaTimingReviewModal.requestCancel(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'playlist-browser',
|
||||
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
||||
@@ -265,6 +272,10 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
@@ -273,6 +284,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
|
||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
@@ -574,6 +586,11 @@ function registerModalOpenHandlers(): void {
|
||||
youtubePickerModal.openYoutubePickerModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenMediaTimingReview((payload) => {
|
||||
runGuarded('media-timing-review:open', () => {
|
||||
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||
runGuardedAsync('playlist-browser:open', async () => {
|
||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||
@@ -805,6 +822,7 @@ async function init(): Promise<void> {
|
||||
jimakuModal.wireDomEvents();
|
||||
tsukihimeModal.wireDomEvents();
|
||||
youtubePickerModal.wireDomEvents();
|
||||
mediaTimingReviewModal.wireDomEvents();
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
|
||||
@@ -64,6 +64,8 @@ export type RendererState = {
|
||||
youtubePickerSecondaryTrackId: string | null;
|
||||
youtubePickerStatus: string;
|
||||
|
||||
mediaTimingReviewModalOpen: boolean;
|
||||
|
||||
kikuModalOpen: boolean;
|
||||
kikuSelectedCard: 1 | 2;
|
||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||
@@ -194,6 +196,8 @@ export function createRendererState(): RendererState {
|
||||
youtubePickerSecondaryTrackId: null,
|
||||
youtubePickerStatus: '',
|
||||
|
||||
mediaTimingReviewModalOpen: false,
|
||||
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1,
|
||||
kikuOriginalData: null,
|
||||
|
||||
@@ -1323,6 +1323,436 @@ body:focus-visible,
|
||||
}
|
||||
}
|
||||
|
||||
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
|
||||
.media-timing-review-modal {
|
||||
background: rgba(24, 25, 38, 0.74);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.media-timing-review-content {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 18px;
|
||||
background: var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
box-shadow:
|
||||
0 26px 80px rgba(24, 25, 38, 0.74),
|
||||
0 0 0 1px rgba(183, 189, 248, 0.08) inset;
|
||||
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||
}
|
||||
|
||||
@keyframes media-timing-review-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.media-timing-review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.media-timing-review-eyebrow {
|
||||
margin-bottom: 3px;
|
||||
color: var(--ctp-teal);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-title {
|
||||
color: var(--ctp-text);
|
||||
font-size: 22px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.media-timing-review-editor {
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.media-timing-review-text {
|
||||
max-height: 108px;
|
||||
overflow: auto;
|
||||
margin: 0 0 18px;
|
||||
padding: 13px 16px;
|
||||
border-left: 3px solid var(--ctp-mauve);
|
||||
border-radius: 0 10px 10px 0;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 16px;
|
||||
font-weight: 560;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.media-timing-review-readout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 12px;
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.media-timing-review-readout > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.media-timing-review-readout > div + div {
|
||||
border-left: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.media-timing-review-readout span {
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-readout strong {
|
||||
color: var(--ctp-lavender);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 16px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.media-timing-review-readout .media-timing-review-duration {
|
||||
align-items: center;
|
||||
background: var(--ctp-surface0);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-timeline-shell {
|
||||
padding: 14px 16px 12px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 14px;
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.media-timing-review-timeline-labels,
|
||||
.media-timing-review-expand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.media-timing-review-timeline-labels span:first-child,
|
||||
.media-timing-review-timeline-labels span:last-child {
|
||||
color: var(--ctp-subtext0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-track {
|
||||
--selection-start: 20%;
|
||||
--selection-end: 80%;
|
||||
|
||||
position: relative;
|
||||
height: 44px;
|
||||
margin: 8px 0 9px;
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent calc(10% - 1px),
|
||||
var(--ctp-surface1) calc(10% - 1px),
|
||||
var(--ctp-surface1) 10%
|
||||
),
|
||||
var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.media-timing-review-selected-range {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
left: var(--selection-start);
|
||||
right: calc(100% - var(--selection-end));
|
||||
border: 1px solid var(--ctp-teal);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-sapphire);
|
||||
box-shadow: 0 0 18px rgba(125, 196, 228, 0.24);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-range {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-webkit-slider-runnable-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-webkit-slider-thumb {
|
||||
width: 15px;
|
||||
height: 34px;
|
||||
margin-top: 4px;
|
||||
appearance: none;
|
||||
border: 2px solid var(--ctp-base);
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-text);
|
||||
box-shadow: 0 2px 7px var(--ctp-crust);
|
||||
cursor: ew-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-moz-range-track {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.media-timing-review-range::-moz-range-thumb {
|
||||
width: 15px;
|
||||
height: 34px;
|
||||
border: 2px solid var(--ctp-base);
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-text);
|
||||
box-shadow: 0 2px 7px var(--ctp-crust);
|
||||
cursor: ew-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.media-timing-review-expand-row span {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button,
|
||||
.media-timing-review-quiet-button,
|
||||
.media-timing-review-original-button,
|
||||
.media-timing-review-discard-button,
|
||||
.media-timing-review-play-button,
|
||||
.media-timing-review-confirm-button,
|
||||
.media-timing-review-fine-control button {
|
||||
border: 1px solid var(--ctp-surface2);
|
||||
border-radius: 8px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 130ms ease,
|
||||
border-color 130ms ease,
|
||||
transform 130ms ease;
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button {
|
||||
padding: 5px 9px;
|
||||
color: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button:disabled,
|
||||
.media-timing-review-content button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.media-timing-review-content button:not(:disabled):hover {
|
||||
border-color: var(--ctp-lavender);
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.media-timing-review-content button:not(:disabled):active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.media-timing-review-content button:focus-visible,
|
||||
.media-timing-review-range:focus-visible::-webkit-slider-thumb {
|
||||
outline: 2px solid var(--ctp-yellow);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 10px;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control span {
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-control button {
|
||||
min-width: 62px;
|
||||
padding: 5px 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.media-timing-review-status {
|
||||
min-height: 21px;
|
||||
padding-top: 7px;
|
||||
color: var(--ctp-teal);
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.media-timing-review-status.is-error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.media-timing-review-footer,
|
||||
.media-timing-review-preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.media-timing-review-footer {
|
||||
justify-content: space-between;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.media-timing-review-play-button,
|
||||
.media-timing-review-confirm-button,
|
||||
.media-timing-review-quiet-button,
|
||||
.media-timing-review-original-button,
|
||||
.media-timing-review-discard-button {
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.media-timing-review-play-button {
|
||||
border-color: var(--ctp-teal);
|
||||
background: var(--ctp-teal);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.media-timing-review-play-button.is-playing {
|
||||
border-color: var(--ctp-yellow);
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.media-timing-review-confirm-button {
|
||||
border-color: var(--ctp-blue);
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.media-timing-review-original-button {
|
||||
border-color: var(--ctp-yellow);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.media-timing-review-discard-button {
|
||||
border-color: var(--ctp-red);
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step {
|
||||
min-height: 330px;
|
||||
padding: 48px 38px 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-mark {
|
||||
display: grid;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin: 0 auto 16px;
|
||||
place-items: center;
|
||||
border: 2px solid var(--ctp-yellow);
|
||||
border-radius: 50%;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 26px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step h2 {
|
||||
color: var(--ctp-text);
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step p {
|
||||
max-width: 520px;
|
||||
margin: 8px auto 26px;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.media-timing-review-content {
|
||||
width: calc(100vw - 18px);
|
||||
max-height: calc(100vh - 18px);
|
||||
}
|
||||
|
||||
.media-timing-review-header,
|
||||
.media-timing-review-editor {
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.media-timing-review-fine-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.media-timing-review-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-timing-review-preview-actions > *,
|
||||
.media-timing-review-confirm-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||
|
||||
@@ -44,6 +44,35 @@ export type RendererDom = {
|
||||
youtubePickerStatus: HTMLDivElement;
|
||||
youtubePickerTracks: HTMLUListElement;
|
||||
|
||||
mediaTimingReviewModal: HTMLDivElement;
|
||||
mediaTimingReviewKind: HTMLDivElement;
|
||||
mediaTimingReviewText: HTMLElement;
|
||||
mediaTimingReviewStartValue: HTMLElement;
|
||||
mediaTimingReviewEndValue: HTMLElement;
|
||||
mediaTimingReviewDuration: HTMLElement;
|
||||
mediaTimingReviewTimelineStart: HTMLElement;
|
||||
mediaTimingReviewTimelineEnd: HTMLElement;
|
||||
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
||||
mediaTimingReviewStartRange: HTMLInputElement;
|
||||
mediaTimingReviewEndRange: HTMLInputElement;
|
||||
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
||||
mediaTimingReviewShowLater: HTMLButtonElement;
|
||||
mediaTimingReviewStartBack: HTMLButtonElement;
|
||||
mediaTimingReviewStartForward: HTMLButtonElement;
|
||||
mediaTimingReviewEndBack: HTMLButtonElement;
|
||||
mediaTimingReviewEndForward: HTMLButtonElement;
|
||||
mediaTimingReviewPlay: HTMLButtonElement;
|
||||
mediaTimingReviewReset: HTMLButtonElement;
|
||||
mediaTimingReviewCancel: HTMLButtonElement;
|
||||
mediaTimingReviewConfirm: HTMLButtonElement;
|
||||
mediaTimingReviewStatus: HTMLDivElement;
|
||||
mediaTimingReviewEditor: HTMLDivElement;
|
||||
mediaTimingReviewCancelStep: HTMLDivElement;
|
||||
mediaTimingReviewCancelMessage: HTMLParagraphElement;
|
||||
mediaTimingReviewCancelBack: HTMLButtonElement;
|
||||
mediaTimingReviewUseOriginal: HTMLButtonElement;
|
||||
mediaTimingReviewDiscard: HTMLButtonElement;
|
||||
|
||||
kikuModal: HTMLDivElement;
|
||||
kikuCard1: HTMLDivElement;
|
||||
kikuCard2: HTMLDivElement;
|
||||
@@ -204,6 +233,53 @@ export function resolveRendererDom(): RendererDom {
|
||||
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
||||
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
||||
|
||||
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
|
||||
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
|
||||
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
|
||||
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
|
||||
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
|
||||
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
|
||||
mediaTimingReviewTimelineStart: getRequiredElement<HTMLElement>(
|
||||
'mediaTimingReviewTimelineStart',
|
||||
),
|
||||
mediaTimingReviewTimelineEnd: getRequiredElement<HTMLElement>('mediaTimingReviewTimelineEnd'),
|
||||
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
||||
'mediaTimingReviewSelectionTrack',
|
||||
),
|
||||
mediaTimingReviewStartRange: getRequiredElement<HTMLInputElement>(
|
||||
'mediaTimingReviewStartRange',
|
||||
),
|
||||
mediaTimingReviewEndRange: getRequiredElement<HTMLInputElement>('mediaTimingReviewEndRange'),
|
||||
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewShowEarlier',
|
||||
),
|
||||
mediaTimingReviewShowLater: getRequiredElement<HTMLButtonElement>('mediaTimingReviewShowLater'),
|
||||
mediaTimingReviewStartBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewStartBack'),
|
||||
mediaTimingReviewStartForward: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewStartForward',
|
||||
),
|
||||
mediaTimingReviewEndBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewEndBack'),
|
||||
mediaTimingReviewEndForward: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewEndForward',
|
||||
),
|
||||
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
||||
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
||||
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
||||
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
||||
mediaTimingReviewStatus: getRequiredElement<HTMLDivElement>('mediaTimingReviewStatus'),
|
||||
mediaTimingReviewEditor: getRequiredElement<HTMLDivElement>('mediaTimingReviewEditor'),
|
||||
mediaTimingReviewCancelStep: getRequiredElement<HTMLDivElement>('mediaTimingReviewCancelStep'),
|
||||
mediaTimingReviewCancelMessage: getRequiredElement<HTMLParagraphElement>(
|
||||
'mediaTimingReviewCancelMessage',
|
||||
),
|
||||
mediaTimingReviewCancelBack: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewCancelBack',
|
||||
),
|
||||
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewUseOriginal',
|
||||
),
|
||||
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
|
||||
|
||||
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
||||
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
||||
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
||||
|
||||
@@ -7,6 +7,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
'youtube-track-picker',
|
||||
'media-timing-review',
|
||||
'playlist-browser',
|
||||
'kiku',
|
||||
'controller-select',
|
||||
@@ -125,6 +126,9 @@ export const IPC_CHANNELS = {
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||
mediaTimingReviewPreview: 'media-timing-review:preview',
|
||||
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -142,6 +146,7 @@ export const IPC_CHANNELS = {
|
||||
jimakuOpen: 'jimaku:open',
|
||||
tsukihimeOpen: 'tsukihime:open',
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
mediaTimingReviewOpen: 'media-timing-review:open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
|
||||
@@ -11,6 +11,54 @@ export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||
export type WordCardKind = CardKind | 'none';
|
||||
|
||||
export type MediaTimingReviewKind = 'word' | 'sentence' | 'audio';
|
||||
|
||||
export interface MediaTimingReviewRequest {
|
||||
kind: MediaTimingReviewKind;
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
noteId?: number;
|
||||
audioPadding: number;
|
||||
maxMediaDuration: number;
|
||||
}
|
||||
|
||||
export type MediaTimingReviewDecision =
|
||||
| { action: 'confirm'; startTime: number; endTime: number }
|
||||
| { action: 'use-original' }
|
||||
| { action: 'discard' };
|
||||
|
||||
export interface MediaTimingReviewOpenPayload {
|
||||
reviewId: string;
|
||||
kind: MediaTimingReviewKind;
|
||||
text: string;
|
||||
noteId?: number;
|
||||
originalStartTime: number;
|
||||
originalEndTime: number;
|
||||
selectionStartTime: number;
|
||||
selectionEndTime: number;
|
||||
timelineStartTime: number;
|
||||
timelineEndTime: number;
|
||||
mediaDuration?: number;
|
||||
maxMediaDuration: number;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewPreviewRequest {
|
||||
reviewId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewResolveRequest {
|
||||
reviewId: string;
|
||||
decision: MediaTimingReviewDecision;
|
||||
}
|
||||
|
||||
export interface MediaTimingReviewActionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NotificationOptions {
|
||||
body?: string;
|
||||
icon?: string;
|
||||
@@ -85,6 +133,7 @@ export interface AnkiConnectConfig {
|
||||
syncAnimatedImageToWordAudio?: boolean;
|
||||
normalizeAudio?: boolean;
|
||||
mirrorMpvVolume?: boolean;
|
||||
reviewTiming?: boolean;
|
||||
audioPadding?: number;
|
||||
fallbackDuration?: number;
|
||||
maxMediaDuration?: number;
|
||||
|
||||
@@ -248,6 +248,7 @@ export interface ResolvedConfig {
|
||||
syncAnimatedImageToWordAudio: boolean;
|
||||
normalizeAudio: boolean;
|
||||
mirrorMpvVolume: boolean;
|
||||
reviewTiming: boolean;
|
||||
audioPadding: number;
|
||||
fallbackDuration: number;
|
||||
maxMediaDuration: number;
|
||||
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
KikuFieldGroupingRequestData,
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewRequest,
|
||||
MediaTimingReviewResolveRequest,
|
||||
} from './anki';
|
||||
import type { ChangelogSnapshot } from './changelog';
|
||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||
@@ -516,6 +520,14 @@ export interface ElectronAPI {
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenTsukihime: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
|
||||
previewMediaTimingReview: (
|
||||
request: MediaTimingReviewPreviewRequest,
|
||||
) => Promise<MediaTimingReviewActionResult>;
|
||||
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
||||
resolveMediaTimingReview: (
|
||||
request: MediaTimingReviewResolveRequest,
|
||||
) => Promise<MediaTimingReviewActionResult>;
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||
@@ -561,6 +573,7 @@ export interface ElectronAPI {
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'media-timing-review'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
@@ -577,6 +590,7 @@ export interface ElectronAPI {
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'youtube-track-picker'
|
||||
| 'media-timing-review'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
|
||||
@@ -243,6 +243,8 @@ export interface SubtitleMiningContext {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
capturedAtMs?: number;
|
||||
/** Explicit generator padding. Confirmed timing-review ranges set this to zero. */
|
||||
mediaPaddingSeconds?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
|
||||
Reference in New Issue
Block a user