feat(anki): add multi-line subtitle selection to media timing review

This commit is contained in:
2026-08-30 01:56:16 -07:00
parent 62d645e577
commit ad1c1aea1b
14 changed files with 562 additions and 20 deletions
+52
View File
@@ -224,6 +224,57 @@
</div>
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
<div class="media-timing-review-sentence-header">
<div class="media-timing-review-sentence-heading">
<span class="media-timing-review-sentence-label">Sentence on card</span>
<span id="mediaTimingReviewLineCount" class="media-timing-review-line-count">
1 line
</span>
</div>
<div
id="mediaTimingReviewLineControls"
class="media-timing-review-line-controls hidden"
>
<div class="media-timing-review-line-stepper">
<span>Prev</span>
<button
id="mediaTimingReviewPrevRemove"
type="button"
aria-label="Remove the earliest added previous subtitle line from the card sentence"
title="Remove the earliest previous line (Shift+P)"
>
&minus;
</button>
<button
id="mediaTimingReviewPrevAdd"
type="button"
aria-label="Add the previous subtitle line to the card sentence"
title="Add the previous line (P)"
>
+
</button>
</div>
<div class="media-timing-review-line-stepper">
<span>Next</span>
<button
id="mediaTimingReviewNextRemove"
type="button"
aria-label="Remove the latest added next subtitle line from the card sentence"
title="Remove the latest next line (Shift+N)"
>
&minus;
</button>
<button
id="mediaTimingReviewNextAdd"
type="button"
aria-label="Add the next subtitle line to the card sentence"
title="Add the next line (N)"
>
+
</button>
</div>
</div>
</div>
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
<div class="media-timing-review-readout" aria-live="polite">
@@ -395,6 +446,7 @@
<div class="media-timing-review-hints" aria-hidden="true">
<span><kbd>Space</kbd> preview</span>
<span><kbd>&larr;</kbd><kbd>&rarr;</kbd> nudge focused edge</span>
<span><kbd>P</kbd>/<kbd>N</kbd> add prev/next line</span>
<span><kbd>Enter</kbd> confirm</span>
<span><kbd>Esc</kbd> cancel</span>
</div>
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMediaTimingLineSelection,
buildMediaTimingWaveformPath,
constrainMediaTimingSelection,
createMediaTimingPreviewRequestGuard,
@@ -73,6 +74,35 @@ test('sliding keeps the clip length and stops at the timeline and media bounds',
});
});
test('line selection combines adjacent lines around the mined one and tracks their range', () => {
const base = {
previousLines: [
{ text: '一行目', startTime: 0, endTime: 2 },
{ text: '二行目', startTime: 2.5, endTime: 4 },
],
nextLines: [
{ text: '四行目', startTime: 7.5, endTime: 9 },
{ text: '五行目', startTime: 9.5, endTime: 11 },
],
text: '採掘行',
originalStartTime: 5,
originalEndTime: 7,
};
const none = buildMediaTimingLineSelection({ ...base, previousCount: 0, nextCount: 0 });
assert.deepEqual(none.lineTexts, ['採掘行']);
assert.equal(none.currentLineIndex, 0);
assert.equal(none.rangeStart, 5);
assert.equal(none.rangeEnd, 7);
const expanded = buildMediaTimingLineSelection({ ...base, previousCount: 1, nextCount: 2 });
assert.deepEqual(expanded.lineTexts, ['二行目', '採掘行', '四行目', '五行目']);
assert.equal(expanded.currentLineIndex, 1);
assert.equal(expanded.sentence, '二行目 採掘行 四行目 五行目');
assert.equal(expanded.rangeStart, 2.5);
assert.equal(expanded.rangeEnd, 11);
});
test('preview request guard blocks overlap and invalidates stale responses', () => {
const guard = createMediaTimingPreviewRequestGuard();
const first = guard.begin();
+174 -9
View File
@@ -1,4 +1,8 @@
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
import type {
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
@@ -6,6 +10,7 @@ const MINIMUM_CLIP_SECONDS = 0.1;
const FINE_ADJUST_SECONDS = 0.1;
const COARSE_ADJUST_SECONDS = 0.5;
const TIMELINE_EXPANSION_SECONDS = 2;
const LINE_REVEAL_MARGIN_SECONDS = 1;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -23,6 +28,43 @@ export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
}
/**
* Resolves which subtitle lines the card sentence currently includes. The counts say
* how many adjacent lines were pulled in on each side; the range covers those lines'
* subtitle timings so the clip edges can follow them.
*/
export function buildMediaTimingLineSelection(options: {
previousLines: MediaTimingReviewContextLine[];
nextLines: MediaTimingReviewContextLine[];
text: string;
originalStartTime: number;
originalEndTime: number;
previousCount: number;
nextCount: number;
}): {
lineTexts: string[];
currentLineIndex: number;
sentence: string;
rangeStart: number;
rangeEnd: number;
} {
const previous =
options.previousCount > 0 ? options.previousLines.slice(-options.previousCount) : [];
const next = options.nextCount > 0 ? options.nextLines.slice(0, options.nextCount) : [];
const lineTexts = [
...previous.map((line) => line.text),
options.text,
...next.map((line) => line.text),
];
return {
lineTexts,
currentLineIndex: previous.length,
sentence: lineTexts.join(' '),
rangeStart: previous[0]?.startTime ?? options.originalStartTime,
rangeEnd: next[next.length - 1]?.endTime ?? options.originalEndTime,
};
}
export function createMediaTimingPreviewRequestGuard() {
let sequence = 0;
let activeRequestId: number | null = null;
@@ -128,6 +170,10 @@ export function createMediaTimingReviewModal(
let selectionEnd = 0;
let timelineStart = 0;
let timelineEnd = 0;
let previousCount = 0;
let nextCount = 0;
let startPadSeconds = 0;
let endPadSeconds = 0;
let resolveInFlight = false;
let previewPlaying = false;
let previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -304,6 +350,86 @@ export function createMediaTimingReviewModal(
}, delayMs);
}
function currentLineSelection(): ReturnType<typeof buildMediaTimingLineSelection> {
return buildMediaTimingLineSelection({
previousLines: payload?.previousLines ?? [],
nextLines: payload?.nextLines ?? [],
text: payload?.text ?? '',
originalStartTime: payload?.originalStartTime ?? 0,
originalEndTime: payload?.originalEndTime ?? 0,
previousCount,
nextCount,
});
}
function renderSentence(): void {
if (!payload) return;
const selection = currentLineSelection();
ctx.dom.mediaTimingReviewText.replaceChildren(
...selection.lineTexts.map((text, index) => {
const line = document.createElement('span');
line.className =
index === selection.currentLineIndex
? 'media-timing-review-line is-current'
: 'media-timing-review-line';
line.textContent = text;
return line;
}),
);
const total = selection.lineTexts.length;
ctx.dom.mediaTimingReviewLineCount.textContent = total === 1 ? '1 line' : `${total} lines`;
const hasContext = payload.previousLines.length > 0 || payload.nextLines.length > 0;
ctx.dom.mediaTimingReviewLineControls.classList.toggle('hidden', !hasContext);
ctx.dom.mediaTimingReviewPrevAdd.disabled = previousCount >= payload.previousLines.length;
ctx.dom.mediaTimingReviewPrevRemove.disabled = previousCount <= 0;
ctx.dom.mediaTimingReviewNextAdd.disabled = nextCount >= payload.nextLines.length;
ctx.dom.mediaTimingReviewNextRemove.disabled = nextCount <= 0;
}
/**
* Adds or removes an adjacent subtitle line from the card sentence, then follows the
* affected clip edge to the new outermost line while keeping the user's other edge.
*/
function adjustLines(direction: 'previous' | 'next', delta: number): void {
if (!payload || resolveInFlight) return;
const available =
direction === 'previous' ? payload.previousLines.length : payload.nextLines.length;
const current = direction === 'previous' ? previousCount : nextCount;
const updated = clamp(current + delta, 0, available);
if (updated === current) return;
if (direction === 'previous') previousCount = updated;
else nextCount = updated;
const selection = currentLineSelection();
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
let timelineChanged = false;
let capped = false;
if (direction === 'previous') {
const target = Math.max(0, selection.rangeStart - startPadSeconds);
if (target < timelineStart) {
timelineStart = Math.max(0, target - LINE_REVEAL_MARGIN_SECONDS);
timelineChanged = true;
}
updateSelection(target, selectionEnd);
capped = selectionStart > target + 0.001;
} else {
const target = Math.min(mediaEnd, selection.rangeEnd + endPadSeconds);
if (target > timelineEnd) {
timelineEnd = Math.min(mediaEnd, target + LINE_REVEAL_MARGIN_SECONDS);
timelineChanged = true;
}
updateSelection(selectionStart, target);
capped = selectionEnd < target - 0.001;
}
renderSentence();
if (capped && payload.maxMediaDuration > 0) {
setStatus(
`Clip length is capped at ${payload.maxMediaDuration}s, so the audio cannot cover every added line.`,
);
}
if (timelineChanged) queueWaveformLoad(120);
}
function updateSelection(nextStart: number, nextEnd: number): void {
if (!payload) return;
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
@@ -507,9 +633,21 @@ export function createMediaTimingReviewModal(
button.disabled = false;
});
renderSelection();
renderSentence();
}
}
function confirmSelection(): void {
if (!payload) return;
const includesAdjacentLines = previousCount > 0 || nextCount > 0;
void resolveReview({
action: 'confirm',
startTime: selectionStart,
endTime: selectionEnd,
...(includesAdjacentLines ? { text: currentLineSelection().sentence } : {}),
});
}
async function togglePreview(): Promise<void> {
if (!payload || resolveInFlight) return;
if (previewPlaying) {
@@ -554,11 +692,19 @@ export function createMediaTimingReviewModal(
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
previewRequest.invalidate();
cancelDrag();
payload = nextPayload;
payload = {
...nextPayload,
previousLines: nextPayload.previousLines ?? [],
nextLines: nextPayload.nextLines ?? [],
};
selectionStart = nextPayload.selectionStartTime;
selectionEnd = nextPayload.selectionEndTime;
timelineStart = nextPayload.timelineStartTime;
timelineEnd = nextPayload.timelineEndTime;
previousCount = 0;
nextCount = 0;
startPadSeconds = Math.max(0, nextPayload.originalStartTime - nextPayload.selectionStartTime);
endPadSeconds = Math.max(0, nextPayload.selectionEndTime - nextPayload.originalEndTime);
resolveInFlight = false;
setPreviewPlaying(false);
ctx.dom.mediaTimingReviewKind.textContent =
@@ -568,12 +714,12 @@ export function createMediaTimingReviewModal(
? 'Audio card'
: 'Sentence card';
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
ctx.dom.mediaTimingReviewDiscard.textContent =
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
setStatus('');
showEditor();
renderSelection();
renderSentence();
ctx.state.mediaTimingReviewModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
@@ -625,7 +771,21 @@ export function createMediaTimingReviewModal(
!(event.target instanceof Element && event.target.closest('button'))
) {
event.preventDefault();
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
confirmSelection();
return true;
}
if (
(event.key === 'p' || event.key === 'P' || event.key === 'n' || event.key === 'N') &&
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
event.preventDefault();
adjustLines(
event.key === 'p' || event.key === 'P' ? 'previous' : 'next',
event.shiftKey ? -1 : 1,
);
return true;
}
return false;
@@ -662,8 +822,17 @@ export function createMediaTimingReviewModal(
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
if (!payload) return;
previousCount = 0;
nextCount = 0;
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
renderSentence();
});
ctx.dom.mediaTimingReviewPrevAdd.addEventListener('click', () => adjustLines('previous', 1));
ctx.dom.mediaTimingReviewPrevRemove.addEventListener('click', () =>
adjustLines('previous', -1),
);
ctx.dom.mediaTimingReviewNextAdd.addEventListener('click', () => adjustLines('next', 1));
ctx.dom.mediaTimingReviewNextRemove.addEventListener('click', () => adjustLines('next', -1));
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
@@ -678,11 +847,7 @@ export function createMediaTimingReviewModal(
'click',
() => void resolveReview({ action: 'discard' }),
);
ctx.dom.mediaTimingReviewConfirm.addEventListener(
'click',
() =>
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
);
ctx.dom.mediaTimingReviewConfirm.addEventListener('click', () => confirmSelection());
}
return {
+89 -5
View File
@@ -1420,11 +1420,82 @@ body:focus-visible,
padding: 14px 20px 18px;
}
.media-timing-review-sentence-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.media-timing-review-sentence-heading {
display: flex;
align-items: center;
gap: 8px;
}
.media-timing-review-sentence-label {
color: var(--ctp-overlay1);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.media-timing-review-line-count {
padding: 2px 8px;
border: 1px solid var(--ctp-surface1);
border-radius: 999px;
background: var(--ctp-mantle);
color: var(--ctp-subtext0);
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.media-timing-review-line-controls {
display: flex;
align-items: center;
gap: 10px;
}
.media-timing-review-line-stepper {
display: flex;
align-items: center;
gap: 4px;
}
.media-timing-review-line-stepper span {
margin-right: 2px;
color: var(--ctp-overlay1);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.media-timing-review-line-stepper button {
width: 24px;
height: 22px;
padding: 0;
border: 1px solid var(--ctp-surface2);
border-radius: 6px;
background: var(--ctp-surface0);
color: var(--ctp-text);
font-size: 13px;
font-weight: 750;
line-height: 1;
cursor: pointer;
}
.media-timing-review-text {
max-height: 88px;
display: flex;
flex-direction: column;
gap: 3px;
max-height: 108px;
overflow: auto;
margin: 0 0 12px;
padding: 10px 14px;
padding: 8px 14px;
border-left: 3px solid var(--ctp-mauve);
border-radius: 0 10px 10px 0;
background: var(--ctp-mantle);
@@ -1435,6 +1506,19 @@ body:focus-visible,
white-space: pre-wrap;
}
.media-timing-review-line {
color: var(--ctp-overlay1);
}
.media-timing-review-line.is-current {
color: var(--ctp-text);
}
/* Only dim context lines when some are actually added. */
.media-timing-review-text .media-timing-review-line:only-child {
color: var(--ctp-subtext1);
}
.media-timing-review-readout {
display: grid;
grid-template-columns: 1fr auto 1fr;
@@ -1521,7 +1605,7 @@ body:focus-visible,
--playhead-duration: 1s;
position: relative;
height: 76px;
height: 52px;
margin: 8px 0 9px;
overflow: hidden;
border: 1px solid var(--ctp-surface2);
@@ -1544,9 +1628,9 @@ body:focus-visible,
.media-timing-review-waveform {
position: absolute;
z-index: 1;
inset: 6px 0;
inset: 4px 0;
width: 100%;
height: calc(100% - 12px);
height: calc(100% - 8px);
pointer-events: none;
transition: opacity 160ms ease;
}
+18
View File
@@ -47,6 +47,12 @@ export type RendererDom = {
mediaTimingReviewModal: HTMLDivElement;
mediaTimingReviewKind: HTMLDivElement;
mediaTimingReviewText: HTMLElement;
mediaTimingReviewLineCount: HTMLElement;
mediaTimingReviewLineControls: HTMLDivElement;
mediaTimingReviewPrevAdd: HTMLButtonElement;
mediaTimingReviewPrevRemove: HTMLButtonElement;
mediaTimingReviewNextAdd: HTMLButtonElement;
mediaTimingReviewNextRemove: HTMLButtonElement;
mediaTimingReviewStartValue: HTMLElement;
mediaTimingReviewEndValue: HTMLElement;
mediaTimingReviewDuration: HTMLElement;
@@ -241,6 +247,18 @@ export function resolveRendererDom(): RendererDom {
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
mediaTimingReviewLineCount: getRequiredElement<HTMLElement>('mediaTimingReviewLineCount'),
mediaTimingReviewLineControls: getRequiredElement<HTMLDivElement>(
'mediaTimingReviewLineControls',
),
mediaTimingReviewPrevAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPrevAdd'),
mediaTimingReviewPrevRemove: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewPrevRemove',
),
mediaTimingReviewNextAdd: getRequiredElement<HTMLButtonElement>('mediaTimingReviewNextAdd'),
mediaTimingReviewNextRemove: getRequiredElement<HTMLButtonElement>(
'mediaTimingReviewNextRemove',
),
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),