mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-13 05:16:18 -07:00
Compare commits
2
Commits
37d182ccea
...
04166f3f01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04166f3f01
|
||
|
|
ecd62edd25
|
@@ -1,4 +1,4 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and explicit cancellation choices.
|
||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, cancellation choices that include keeping a card without media, and a session-only runtime toggle.
|
||||
|
||||
@@ -179,9 +179,9 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels; buttons reveal another five seconds before or after the visible timeline. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels. The Earlier and Later buttons reveal another two seconds of available timeline without moving the selected clip. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||
|
||||
Canceling the review lets you keep editing, finish with the original timing, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
||||
Canceling the review lets you keep editing, finish with the original timing, keep or create the card without audio or an image, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads. You can also toggle **Review Media Timing** for the current session from the runtime options palette (`Ctrl/Cmd+Shift+O`).
|
||||
|
||||
### Screenshots (Static)
|
||||
|
||||
|
||||
@@ -873,9 +873,10 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
||||
|
||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||
|
||||
Current runtime options cover automatic card updates, known-word highlighting,
|
||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
||||
Current runtime options cover automatic card updates, media timing review,
|
||||
known-word highlighting, known-word maturity coloring, N+1 annotation, JLPT
|
||||
underlines, frequency highlighting, known-word match mode, and Kiku field
|
||||
grouping mode.
|
||||
|
||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
|
||||
@@ -1020,7 +1021,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
||||
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
|
||||
@@ -432,3 +432,50 @@ test('discarding an audio-card timing review deletes the note before evicting it
|
||||
assert.deepEqual(events, ['delete:42', 'cache:42']);
|
||||
assert.deepEqual(statusMessages, ['Card deleted.']);
|
||||
});
|
||||
|
||||
test('keeping an audio card without media skips generation and preserves the note', async () => {
|
||||
let generatedAudio = false;
|
||||
let deleted = false;
|
||||
const { service, storedMedia } = 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: '単語' }, Sentence: { value: '' } },
|
||||
},
|
||||
],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
deleteNotes: async () => {
|
||||
deleted = true;
|
||||
},
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => {
|
||||
generatedAudio = true;
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
reviewMediaTiming: async () => ({ action: 'skip-media' }),
|
||||
});
|
||||
|
||||
await service.markLastCardAsAudioCard();
|
||||
|
||||
assert.equal(generatedAudio, false);
|
||||
assert.equal(deleted, false);
|
||||
assert.deepEqual(storedMedia, []);
|
||||
});
|
||||
|
||||
@@ -153,4 +153,11 @@ test('sentence card writes generated audio only to sentence audio field', async
|
||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||
assert.equal(addedFields.length, 1);
|
||||
|
||||
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
|
||||
assert.equal(addedFields.length, 2);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
||||
assert.deepEqual(requestedProperties, ['volume']);
|
||||
});
|
||||
|
||||
@@ -478,6 +478,7 @@ export class CardCreationService {
|
||||
this.deps.showStatusNotification('Card deleted.');
|
||||
return;
|
||||
}
|
||||
const skipMedia = timingDecision.action === 'skip-media';
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
@@ -498,26 +499,28 @@ export class CardCreationService {
|
||||
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const audioFieldName = sentenceCardConfig.audioField;
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
);
|
||||
if (!skipMedia) {
|
||||
try {
|
||||
const audioFilename = this.generateAudioFilename();
|
||||
const audioBuffer = await this.mediaGenerateAudio(
|
||||
mpvClient.currentVideoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
exactReviewedRange ? 0 : undefined,
|
||||
);
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
miscInfoFilename = audioFilename;
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
miscInfoFilename = audioFilename;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate audio for audio card:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to generate audio for audio card:', (error as Error).message);
|
||||
errors.push('audio');
|
||||
}
|
||||
|
||||
if (shouldGenerateImage(this.deps.getConfig())) {
|
||||
if (!skipMedia && shouldGenerateImage(this.deps.getConfig())) {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.generateImageFilename();
|
||||
@@ -611,6 +614,7 @@ export class CardCreationService {
|
||||
this.deps.showStatusNotification('Card creation cancelled.');
|
||||
return false;
|
||||
}
|
||||
const skipMedia = timingDecision.action === 'skip-media';
|
||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||
if (timingDecision.action === 'confirm') {
|
||||
startTime = timingDecision.startTime;
|
||||
@@ -618,8 +622,8 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
const config = this.deps.getConfig();
|
||||
const generateAudio = shouldGenerateAudio(config);
|
||||
const generateImage = shouldGenerateImage(config);
|
||||
const generateAudio = !skipMedia && shouldGenerateAudio(config);
|
||||
const generateImage = !skipMedia && shouldGenerateImage(config);
|
||||
const mediaResolverOptions = this.getMediaResolverOptions();
|
||||
const videoPath = generateImage
|
||||
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
|
||||
|
||||
@@ -626,6 +626,48 @@ test('NoteUpdateWorkflow deletes an existing word card when timing review discar
|
||||
assert.deepEqual(harness.notifications, []);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow keeps the word card but skips media after timing review', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const mediaCalls: string[] = [];
|
||||
const deletedNoteIds: number[][] = [];
|
||||
const queuedUpdates: unknown[] = [];
|
||||
harness.deps.captureSubtitleMediaContext = () => ({
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
});
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: { sentence: 'Sentence', image: 'Picture' },
|
||||
media: { generateAudio: true, generateImage: true },
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
|
||||
harness.deps.generateAudio = async () => {
|
||||
mediaCalls.push('audio');
|
||||
return Buffer.from('audio');
|
||||
};
|
||||
harness.deps.generateImage = async () => {
|
||||
mediaCalls.push('image');
|
||||
return Buffer.from('image');
|
||||
};
|
||||
harness.deps.queuePendingYoutubeMediaUpdate = async (update) => {
|
||||
queuedUpdates.push(update);
|
||||
return true;
|
||||
};
|
||||
harness.deps.client.deleteNotes = async (noteIds) => {
|
||||
deletedNoteIds.push(noteIds);
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.deepEqual(mediaCalls, []);
|
||||
assert.deepEqual(queuedUpdates, []);
|
||||
assert.deepEqual(deletedNoteIds, []);
|
||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||
assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const statusMessages: string[] = [];
|
||||
|
||||
@@ -218,6 +218,7 @@ export class NoteUpdateWorkflow {
|
||||
// timings per generator clips whichever line is on screen when each one starts.
|
||||
let mediaTimingContext =
|
||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||
let skipMedia = false;
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
if (mediaTimingContext) {
|
||||
@@ -250,6 +251,8 @@ export class NoteUpdateWorkflow {
|
||||
endTime: timingDecision.endTime,
|
||||
mediaPaddingSeconds: 0,
|
||||
};
|
||||
} else if (timingDecision.action === 'skip-media') {
|
||||
skipMedia = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,8 +286,8 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
const generateAudio = config.media?.generateAudio !== false;
|
||||
const generateImage = config.media?.generateImage !== false;
|
||||
const generateAudio = !skipMedia && config.media?.generateAudio !== false;
|
||||
const generateImage = !skipMedia && config.media?.generateImage !== false;
|
||||
const mediaCacheQueued =
|
||||
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
|
||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||
|
||||
@@ -2181,6 +2181,7 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'anki.mediaReviewTiming',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.knownWords.maturityEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
|
||||
@@ -202,6 +202,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||
description:
|
||||
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
||||
runtime: runtimeOptionById.get('anki.mediaReviewTiming'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
|
||||
@@ -19,6 +19,20 @@ export function buildRuntimeOptionRegistry(
|
||||
behavior: { autoUpdateNewCards: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'anki.mediaReviewTiming',
|
||||
path: 'ankiConnect.media.reviewTiming',
|
||||
label: 'Review Media Timing',
|
||||
scope: 'ankiConnect',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: (value) => ({
|
||||
media: { reviewTiming: value === true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
|
||||
@@ -352,6 +352,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
|
||||
@@ -33,6 +33,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
next.ankiConnect.deck = 'Mining';
|
||||
next.ankiConnect.media.normalizeAudio = !prev.ankiConnect.media.normalizeAudio;
|
||||
next.ankiConnect.media.mirrorMpvVolume = !prev.ankiConnect.media.mirrorMpvVolume;
|
||||
next.ankiConnect.media.reviewTiming = !prev.ankiConnect.media.reviewTiming;
|
||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||
@@ -69,6 +70,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -70,6 +70,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'ankiConnect.media.mirrorMpvVolume',
|
||||
'ankiConnect.media.reviewTiming',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -648,6 +648,28 @@ test('registerIpcHandlers exposes playback window activation request', async ()
|
||||
assert.deepEqual(calls, ['activate']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers accepts the keep-without-media timing decision', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const requests: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
resolveMediaTimingReview: async (request) => {
|
||||
requests.push(request);
|
||||
return { ok: true };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.handle.get(IPC_CHANNELS.request.mediaTimingReviewResolve);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(
|
||||
await handler!({}, { reviewId: 'review-1', decision: { action: 'skip-media' } }),
|
||||
{ ok: true },
|
||||
);
|
||||
assert.deepEqual(requests, [{ reviewId: 'review-1', decision: { action: 'skip-media' } }]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -275,7 +275,11 @@ function parseMediaTimingReviewResolveRequest(
|
||||
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') {
|
||||
if (
|
||||
decisionRecord.action === 'use-original' ||
|
||||
decisionRecord.action === 'skip-media' ||
|
||||
decisionRecord.action === 'discard'
|
||||
) {
|
||||
return { reviewId: record.reviewId, decision: { action: decisionRecord.action } };
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -156,6 +156,7 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.media.normalizeAudio = false;
|
||||
config.ankiConnect.media.mirrorMpvVolume = false;
|
||||
config.ankiConnect.media.reviewTiming = true;
|
||||
const ankiPatches: unknown[] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
@@ -181,10 +182,18 @@ test('createConfigHotReloadAppliedHandler applies only changed Anki media option
|
||||
},
|
||||
config,
|
||||
);
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['ankiConnect.media.reviewTiming'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{ media: { normalizeAudio: false } },
|
||||
{ media: { mirrorMpvVolume: false } },
|
||||
{ media: { reviewTiming: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.mirrorMpvVolume')) {
|
||||
mediaPatch.mirrorMpvVolume = config.ankiConnect.media.mirrorMpvVolume;
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.media.reviewTiming')) {
|
||||
mediaPatch.reviewTiming = config.ankiConnect.media.reviewTiming;
|
||||
}
|
||||
if (Object.keys(mediaPatch).length > 0) {
|
||||
patch.media = mediaPatch;
|
||||
}
|
||||
|
||||
+21
-5
@@ -271,6 +271,12 @@
|
||||
<span class="media-timing-review-original-boundary is-start"></span>
|
||||
<span class="media-timing-review-original-boundary is-end"></span>
|
||||
</div>
|
||||
<span class="media-timing-review-original-label is-start" aria-hidden="true">
|
||||
Line start
|
||||
</span>
|
||||
<span class="media-timing-review-original-label is-end" aria-hidden="true">
|
||||
Line end
|
||||
</span>
|
||||
<div
|
||||
id="mediaTimingReviewSelectedRange"
|
||||
class="media-timing-review-selected-range"
|
||||
@@ -299,18 +305,20 @@
|
||||
id="mediaTimingReviewShowEarlier"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
aria-label="Reveal five more seconds before the timeline"
|
||||
aria-label="Show two more seconds before the visible timeline without moving the selected clip"
|
||||
title="Show 2 more seconds before the visible timeline. The selected clip does not move."
|
||||
>
|
||||
−5s
|
||||
Earlier −2s
|
||||
</button>
|
||||
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
|
||||
<span>Drag an edge to trim, or drag the highlighted clip to move it.</span>
|
||||
<button
|
||||
id="mediaTimingReviewShowLater"
|
||||
class="media-timing-review-expand-button"
|
||||
type="button"
|
||||
aria-label="Reveal five more seconds after the timeline"
|
||||
aria-label="Show two more seconds after the visible timeline without moving the selected clip"
|
||||
title="Show 2 more seconds after the visible timeline. The selected clip does not move."
|
||||
>
|
||||
+5s
|
||||
Later +2s
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,6 +419,14 @@
|
||||
>
|
||||
Use original timing
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewSkipMedia"
|
||||
class="media-timing-review-skip-button"
|
||||
type="button"
|
||||
title="Keep this card but do not add audio or an image."
|
||||
>
|
||||
Keep without media
|
||||
</button>
|
||||
<button
|
||||
id="mediaTimingReviewDiscard"
|
||||
class="media-timing-review-discard-button"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createModalFocusGuard } from './modal-focus-guard';
|
||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||
const FINE_ADJUST_SECONDS = 0.1;
|
||||
const COARSE_ADJUST_SECONDS = 0.5;
|
||||
const TIMELINE_EXPANSION_SECONDS = 5;
|
||||
const TIMELINE_EXPANSION_SECONDS = 2;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
@@ -447,8 +447,10 @@ export function createMediaTimingReviewModal(
|
||||
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.';
|
||||
? 'Keep editing, keep this card without media, use its original timing, or delete it.'
|
||||
: 'Keep editing, create this card without media, use its original timing, or do not create it.';
|
||||
ctx.dom.mediaTimingReviewSkipMedia.textContent =
|
||||
payload.noteId !== undefined ? 'Keep without media' : 'Create without media';
|
||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
||||
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
||||
ctx.dom.mediaTimingReviewCancelBack.focus();
|
||||
@@ -668,6 +670,10 @@ export function createMediaTimingReviewModal(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'use-original' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSkipMedia.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'skip-media' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewDiscard.addEventListener(
|
||||
'click',
|
||||
() => void resolveReview({ action: 'discard' }),
|
||||
|
||||
+18
-8
@@ -1594,30 +1594,32 @@ body:focus-visible,
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.media-timing-review-original-boundary::after {
|
||||
.media-timing-review-original-label {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--ctp-crust) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
background: var(--ctp-peach);
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
||||
color: var(--ctp-crust);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-timing-review-original-boundary.is-start::after {
|
||||
.media-timing-review-original-label.is-start {
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
content: 'line start';
|
||||
left: calc(var(--original-start) + 7px);
|
||||
}
|
||||
|
||||
.media-timing-review-original-boundary.is-end::after {
|
||||
right: 5px;
|
||||
.media-timing-review-original-label.is-end {
|
||||
right: calc(100% - var(--original-end) + 7px);
|
||||
bottom: 5px;
|
||||
content: 'line end';
|
||||
}
|
||||
|
||||
.media-timing-review-track.is-loading::after {
|
||||
@@ -1749,6 +1751,7 @@ body:focus-visible,
|
||||
.media-timing-review-expand-button,
|
||||
.media-timing-review-quiet-button,
|
||||
.media-timing-review-original-button,
|
||||
.media-timing-review-skip-button,
|
||||
.media-timing-review-discard-button,
|
||||
.media-timing-review-play-button,
|
||||
.media-timing-review-confirm-button,
|
||||
@@ -1768,7 +1771,8 @@ body:focus-visible,
|
||||
}
|
||||
|
||||
.media-timing-review-expand-button {
|
||||
padding: 5px 10px;
|
||||
min-width: 82px;
|
||||
padding: 5px 9px;
|
||||
color: var(--ctp-sky);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1853,6 +1857,7 @@ body:focus-visible,
|
||||
.media-timing-review-confirm-button,
|
||||
.media-timing-review-quiet-button,
|
||||
.media-timing-review-original-button,
|
||||
.media-timing-review-skip-button,
|
||||
.media-timing-review-discard-button {
|
||||
padding: 9px 15px;
|
||||
}
|
||||
@@ -1897,6 +1902,11 @@ body:focus-visible,
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.media-timing-review-skip-button {
|
||||
border-color: var(--ctp-sky);
|
||||
color: var(--ctp-sky);
|
||||
}
|
||||
|
||||
.media-timing-review-discard-button {
|
||||
border-color: var(--ctp-red);
|
||||
background: var(--ctp-red);
|
||||
|
||||
@@ -75,6 +75,7 @@ export type RendererDom = {
|
||||
mediaTimingReviewCancelMessage: HTMLParagraphElement;
|
||||
mediaTimingReviewCancelBack: HTMLButtonElement;
|
||||
mediaTimingReviewUseOriginal: HTMLButtonElement;
|
||||
mediaTimingReviewSkipMedia: HTMLButtonElement;
|
||||
mediaTimingReviewDiscard: HTMLButtonElement;
|
||||
|
||||
kikuModal: HTMLDivElement;
|
||||
@@ -292,6 +293,7 @@ export function resolveRendererDom(): RendererDom {
|
||||
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
|
||||
'mediaTimingReviewUseOriginal',
|
||||
),
|
||||
mediaTimingReviewSkipMedia: getRequiredElement<HTMLButtonElement>('mediaTimingReviewSkipMedia'),
|
||||
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
|
||||
|
||||
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
||||
|
||||
@@ -82,3 +82,19 @@ test('RuntimeOptionsManager keeps known-word and n+1 annotation toggles separate
|
||||
assert.equal(effective.nPlusOne?.enabled, true);
|
||||
assert.deepEqual(patches, []);
|
||||
});
|
||||
|
||||
test('RuntimeOptionsManager applies media timing review to the live Anki config', () => {
|
||||
const baseConfig = structuredClone(DEFAULT_CONFIG.ankiConnect);
|
||||
const patches: unknown[] = [];
|
||||
const manager = new RuntimeOptionsManager(() => structuredClone(baseConfig), {
|
||||
applyAnkiPatch: (patch) => {
|
||||
patches.push(patch);
|
||||
},
|
||||
onOptionsChanged: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(manager.getOptionValue('anki.mediaReviewTiming'), false);
|
||||
assert.equal(manager.setOptionValue('anki.mediaReviewTiming', true).ok, true);
|
||||
assert.equal(manager.getEffectiveAnkiConnectConfig().media?.reviewTiming, true);
|
||||
assert.deepEqual(patches, [{ media: { reviewTiming: true } }]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
|
||||
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'anki.autoUpdateNewCards',
|
||||
'anki.mediaReviewTiming',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.knownWords.maturityEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface MediaTimingReviewRequest {
|
||||
export type MediaTimingReviewDecision =
|
||||
| { action: 'confirm'; startTime: number; endTime: number }
|
||||
| { action: 'use-original' }
|
||||
| { action: 'skip-media' }
|
||||
| { action: 'discard' };
|
||||
|
||||
export interface MediaTimingReviewOpenPayload {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type RuntimeOptionId =
|
||||
| 'anki.autoUpdateNewCards'
|
||||
| 'anki.mediaReviewTiming'
|
||||
| 'subtitle.annotation.knownWords.highlightEnabled'
|
||||
| 'subtitle.annotation.knownWords.maturityEnabled'
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
|
||||
Reference in New Issue
Block a user