mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
feat(anki): add media timing review before card creation (#203)
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
import type {
|
||||
MediaTimingReviewContextLine,
|
||||
MediaTimingReviewDecision,
|
||||
MediaTimingReviewOpenPayload,
|
||||
} from '../../types/anki';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
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 = 2;
|
||||
const LINE_REVEAL_MARGIN_SECONDS = 1;
|
||||
/**
|
||||
* Subtitles usually linger past the dialogue for readability, so an untouched clip end
|
||||
* follows the last speech-weighted waveform slice above this level (0..1, relative to the
|
||||
* clip's own noise floor) plus a short tail, when that saves at least the minimum trim.
|
||||
*/
|
||||
const SPEECH_LEVEL_THRESHOLD = 0.3;
|
||||
const SPEECH_TAIL_SECONDS = 0.15;
|
||||
const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
|
||||
/** Slack past the clip length before the UI gives up waiting for mpv's end-of-clip signal. */
|
||||
const PREVIEW_END_GRACE_MS = 2_500;
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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 buildMediaTimingWaveformPath(peaks: number[]): string {
|
||||
if (peaks.length < 2) return '';
|
||||
const points = peaks.map((peak, index) => ({
|
||||
x: (index / (peaks.length - 1)) * 1_000,
|
||||
amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
|
||||
}));
|
||||
const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
|
||||
const lower = [...points]
|
||||
.reverse()
|
||||
.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
|
||||
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an untouched clip should end once the waveform is known: just after the line's
|
||||
* last speech slice, plus the configured end padding. Null keeps the subtitle timing when
|
||||
* no speech shows inside the line, when speech runs through the line end (the subtitle is
|
||||
* cutting the audio off, not lingering), or when the saving is too small to matter.
|
||||
*/
|
||||
export function trimMediaTimingSelectionEnd(options: {
|
||||
peaks: readonly number[];
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
selectionEnd: number;
|
||||
endPadSeconds: number;
|
||||
}): number | null {
|
||||
const pointCount = options.peaks.length;
|
||||
const span = options.timelineEnd - options.timelineStart;
|
||||
if (pointCount < 2 || span <= 0 || options.lineEnd <= options.lineStart) return null;
|
||||
// Point i covers [i, i + 1) / pointCount of the timeline (see computeWaveformPeaks).
|
||||
const sliceEnd = (index: number): number =>
|
||||
options.timelineStart + ((index + 1) / pointCount) * span;
|
||||
const firstIndex = Math.max(
|
||||
0,
|
||||
Math.floor(((options.lineStart - options.timelineStart) / span) * pointCount),
|
||||
);
|
||||
const lastIndex = Math.min(
|
||||
pointCount - 1,
|
||||
Math.ceil(((options.lineEnd - options.timelineStart) / span) * pointCount) - 1,
|
||||
);
|
||||
let lastSpeechIndex = -1;
|
||||
for (let index = lastIndex; index >= firstIndex; index -= 1) {
|
||||
if ((options.peaks[index] ?? 0) >= SPEECH_LEVEL_THRESHOLD) {
|
||||
lastSpeechIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastSpeechIndex === -1 || lastSpeechIndex >= lastIndex) return null;
|
||||
const trimmedEnd = sliceEnd(lastSpeechIndex) + SPEECH_TAIL_SECONDS + options.endPadSeconds;
|
||||
if (options.selectionEnd - trimmedEnd < MINIMUM_TRAILING_TRIM_SECONDS) return null;
|
||||
return trimmedEnd;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a pointer position over the timeline track back onto a media timestamp. */
|
||||
export function mediaTimingTimeFromPointer(options: {
|
||||
clientX: number;
|
||||
trackLeft: number;
|
||||
trackWidth: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
}): number {
|
||||
if (options.trackWidth <= 0) return options.timelineStart;
|
||||
const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
|
||||
return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
|
||||
}
|
||||
|
||||
/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
|
||||
export function slideMediaTimingSelection(options: {
|
||||
nextStart: number;
|
||||
span: number;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
mediaEnd: number;
|
||||
}): { start: number; end: number } {
|
||||
const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
|
||||
const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
|
||||
const start = clamp(options.nextStart, options.timelineStart, maxStart);
|
||||
return { start, end: start + options.span };
|
||||
}
|
||||
|
||||
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 previousCount = 0;
|
||||
let nextCount = 0;
|
||||
let startPadSeconds = 0;
|
||||
let endPadSeconds = 0;
|
||||
/** True until the user moves the clip; the first waveform then trims trailing silence. */
|
||||
let trailingTrimPending = false;
|
||||
let resolveInFlight = false;
|
||||
let previewPlaying = false;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waveformTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waveformSequence = 0;
|
||||
let drag: {
|
||||
edge: 'start' | 'end' | 'both';
|
||||
pointerId: number;
|
||||
trackLeft: number;
|
||||
trackWidth: number;
|
||||
grabOffset: number;
|
||||
} | null = null;
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
||||
getModalRoot: () => ctx.dom.mediaTimingReviewModal,
|
||||
getPreferredFocusTargets: () => [
|
||||
ctx.dom.mediaTimingReviewStartHandle,
|
||||
ctx.dom.mediaTimingReviewCancelBack,
|
||||
],
|
||||
getFallbackFocusTarget: () => ctx.dom.mediaTimingReviewCancel,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the play button label plus the playhead sweep that mirrors the hidden audio player.
|
||||
* mpv reports when the clip actually finishes (see handlePreviewEnded), which accounts for
|
||||
* output latency such as Bluetooth headphones; the timer only covers a player that never does.
|
||||
*/
|
||||
function setPreviewPlaying(playing: boolean): void {
|
||||
previewPlaying = playing;
|
||||
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
||||
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
||||
clearPreviewTimer();
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
track.classList.remove('is-previewing');
|
||||
if (!playing) return;
|
||||
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
|
||||
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
||||
void track.offsetWidth;
|
||||
track.classList.add('is-previewing');
|
||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000 + PREVIEW_END_GRACE_MS);
|
||||
}
|
||||
|
||||
/** The hidden player reached the end of the clip and paused itself. */
|
||||
function handlePreviewEnded(reviewId: string): void {
|
||||
if (!payload || payload.reviewId !== reviewId || !previewPlaying) return;
|
||||
setPreviewPlaying(false);
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
/** Callers that need to report a failure set their own status after stopping the preview. */
|
||||
function stopPreview(): void {
|
||||
previewRequest.invalidate();
|
||||
setPreviewPlaying(false);
|
||||
setStatus('');
|
||||
if (payload) {
|
||||
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderHandle(handle: HTMLDivElement, value: number): void {
|
||||
handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
|
||||
handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
|
||||
handle.setAttribute('aria-valuenow', value.toFixed(3));
|
||||
handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
|
||||
}
|
||||
|
||||
function renderSelection(): void {
|
||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
||||
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
||||
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
|
||||
const lineRange = currentLineSelection();
|
||||
const originalStartPercent = payload
|
||||
? ((lineRange.rangeStart - timelineStart) / span) * 100
|
||||
: 0;
|
||||
const originalEndPercent = payload ? ((lineRange.rangeEnd - timelineStart) / span) * 100 : 0;
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--selection-start',
|
||||
`${clamp(startPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--selection-end',
|
||||
`${clamp(endPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--original-start',
|
||||
`${clamp(originalStartPercent, 0, 100)}%`,
|
||||
);
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
||||
'--original-end',
|
||||
`${clamp(originalEndPercent, 0, 100)}%`,
|
||||
);
|
||||
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
|
||||
renderHandle(ctx.dom.mediaTimingReviewEndHandle, 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 setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
|
||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
|
||||
'is-waveform-unavailable',
|
||||
state === 'unavailable',
|
||||
);
|
||||
ctx.dom.mediaTimingReviewWaveformLabel.textContent =
|
||||
state === 'loading'
|
||||
? 'isolating dialogue...'
|
||||
: state === 'ready'
|
||||
? 'speech-weighted waveform'
|
||||
: 'waveform unavailable';
|
||||
}
|
||||
|
||||
async function loadWaveform(): Promise<void> {
|
||||
if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
|
||||
waveformSequence += 1;
|
||||
const sequence = waveformSequence;
|
||||
const reviewId = payload.reviewId;
|
||||
const startTime = timelineStart;
|
||||
const endTime = timelineEnd;
|
||||
setWaveformState('loading');
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||
try {
|
||||
const result = await window.electronAPI.getMediaTimingReviewWaveform({
|
||||
reviewId,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
if (
|
||||
sequence !== waveformSequence ||
|
||||
payload?.reviewId !== reviewId ||
|
||||
timelineStart !== startTime ||
|
||||
timelineEnd !== endTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok && result.stale) {
|
||||
closeResolvedReview();
|
||||
return;
|
||||
}
|
||||
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
|
||||
if (!path) {
|
||||
setWaveformState('unavailable');
|
||||
return;
|
||||
}
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
|
||||
setWaveformState('ready');
|
||||
trimTrailingSilence(result.peaks ?? []);
|
||||
} catch {
|
||||
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
|
||||
setWaveformState('unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an untouched clip end back to where the line's dialogue ends. The Line end
|
||||
* rail keeps marking the subtitle timing, and Reset restores it.
|
||||
*/
|
||||
function trimTrailingSilence(peaks: readonly number[]): void {
|
||||
if (!payload || !trailingTrimPending || previewPlaying || previewRequest.isInFlight()) {
|
||||
return;
|
||||
}
|
||||
const lineRange = currentLineSelection();
|
||||
const trimmedEnd = trimMediaTimingSelectionEnd({
|
||||
peaks,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
lineStart: lineRange.rangeStart,
|
||||
lineEnd: lineRange.rangeEnd,
|
||||
selectionEnd,
|
||||
endPadSeconds,
|
||||
});
|
||||
if (trimmedEnd === null) return;
|
||||
updateSelection(selectionStart, trimmedEnd);
|
||||
setStatus('Clip end moved to where the dialogue ends. Reset restores the subtitle timing.');
|
||||
}
|
||||
|
||||
function queueWaveformLoad(delayMs = 0): void {
|
||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||
waveformSequence += 1;
|
||||
if (payload && ctx.state.mediaTimingReviewModalOpen) {
|
||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
||||
setWaveformState('loading');
|
||||
}
|
||||
waveformTimer = setTimeout(() => {
|
||||
waveformTimer = null;
|
||||
void loadWaveform();
|
||||
}, 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;
|
||||
const nextSelection = constrainMediaTimingSelection({
|
||||
nextStart,
|
||||
nextEnd,
|
||||
currentStart: selectionStart,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
mediaEnd,
|
||||
maxMediaDuration: payload.maxMediaDuration,
|
||||
});
|
||||
selectionStart = nextSelection.start;
|
||||
selectionEnd = nextSelection.end;
|
||||
trailingTrimPending = false;
|
||||
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
|
||||
setStatus('');
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
/** Shifts the whole clip without changing its length. */
|
||||
function moveSelection(nextStart: number): void {
|
||||
if (!payload) return;
|
||||
const slid = slideMediaTimingSelection({
|
||||
nextStart,
|
||||
span: selectionEnd - selectionStart,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
||||
});
|
||||
updateSelection(slid.start, slid.end);
|
||||
}
|
||||
|
||||
function setEdge(edge: 'start' | 'end', value: number): void {
|
||||
if (edge === 'start') updateSelection(value, selectionEnd);
|
||||
else updateSelection(selectionStart, value);
|
||||
}
|
||||
|
||||
function applyDrag(clientX: number): void {
|
||||
if (!drag) return;
|
||||
const pointerTime = mediaTimingTimeFromPointer({
|
||||
clientX,
|
||||
trackLeft: drag.trackLeft,
|
||||
trackWidth: drag.trackWidth,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
});
|
||||
const time = pointerTime - drag.grabOffset;
|
||||
if (drag.edge === 'both') moveSelection(time);
|
||||
else setEdge(drag.edge, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
|
||||
* and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
|
||||
*/
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pointerTime = mediaTimingTimeFromPointer({
|
||||
clientX: event.clientX,
|
||||
trackLeft: rect.left,
|
||||
trackWidth: rect.width,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
});
|
||||
const target = event.target;
|
||||
let edge: 'start' | 'end' | 'both';
|
||||
let grabOffset: number;
|
||||
if (target === ctx.dom.mediaTimingReviewStartHandle) {
|
||||
edge = 'start';
|
||||
grabOffset = pointerTime - selectionStart;
|
||||
} else if (target === ctx.dom.mediaTimingReviewEndHandle) {
|
||||
edge = 'end';
|
||||
grabOffset = pointerTime - selectionEnd;
|
||||
} else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
|
||||
edge = 'both';
|
||||
grabOffset = pointerTime - selectionStart;
|
||||
} else {
|
||||
edge =
|
||||
Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
|
||||
? 'start'
|
||||
: 'end';
|
||||
grabOffset = 0;
|
||||
}
|
||||
event.preventDefault();
|
||||
drag = {
|
||||
edge,
|
||||
pointerId: event.pointerId,
|
||||
trackLeft: rect.left,
|
||||
trackWidth: rect.width,
|
||||
grabOffset,
|
||||
};
|
||||
track.setPointerCapture(event.pointerId);
|
||||
ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
|
||||
if (edge !== 'both') {
|
||||
const handle =
|
||||
edge === 'start'
|
||||
? ctx.dom.mediaTimingReviewStartHandle
|
||||
: ctx.dom.mediaTimingReviewEndHandle;
|
||||
handle.focus();
|
||||
if (grabOffset === 0) applyDrag(event.clientX);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent): void {
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
|
||||
drag = null;
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
drag = null;
|
||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
||||
}
|
||||
|
||||
function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
|
||||
const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
|
||||
const current = edge === 'start' ? selectionStart : selectionEnd;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
|
||||
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
|
||||
else if (event.key === 'Home') setEdge(edge, timelineStart);
|
||||
else if (event.key === 'End') setEdge(edge, timelineEnd);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
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, 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();
|
||||
}
|
||||
|
||||
function closeResolvedReview(): void {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
||||
cancelDrag();
|
||||
clearPreviewTimer();
|
||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
||||
waveformTimer = null;
|
||||
waveformSequence += 1;
|
||||
previewPlaying = false;
|
||||
ctx.state.mediaTimingReviewModalOpen = false;
|
||||
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
|
||||
focus.detach();
|
||||
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 && !result.stale) {
|
||||
setStatus(result.message ?? 'The timing review could not be resolved.', true);
|
||||
showEditor();
|
||||
return;
|
||||
}
|
||||
// A stale review was already settled by main; keeping the modal up would leave
|
||||
// controls that can never succeed over a live mpv window.
|
||||
closeResolvedReview();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
showEditor();
|
||||
} finally {
|
||||
resolveInFlight = false;
|
||||
controls.forEach((button) => {
|
||||
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) {
|
||||
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 && result.stale) {
|
||||
closeResolvedReview();
|
||||
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();
|
||||
cancelDrag();
|
||||
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);
|
||||
trailingTrimPending = true;
|
||||
resolveInFlight = false;
|
||||
setPreviewPlaying(false);
|
||||
ctx.dom.mediaTimingReviewKind.textContent =
|
||||
nextPayload.kind === 'word'
|
||||
? 'Word card'
|
||||
: nextPayload.kind === 'audio'
|
||||
? 'Audio card'
|
||||
: 'Sentence card';
|
||||
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
||||
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');
|
||||
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');
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
focus.enforceModalFocus();
|
||||
queueWaveformLoad();
|
||||
}
|
||||
|
||||
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();
|
||||
queueWaveformLoad(120);
|
||||
}
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
||||
track.addEventListener('pointerdown', beginDrag);
|
||||
track.addEventListener('pointermove', (event) => {
|
||||
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
|
||||
});
|
||||
track.addEventListener('pointerup', endDrag);
|
||||
track.addEventListener('pointercancel', endDrag);
|
||||
ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
|
||||
handleEdgeKeydown(event, 'start'),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
|
||||
handleEdgeKeydown(event, 'end'),
|
||||
);
|
||||
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;
|
||||
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(
|
||||
'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' }),
|
||||
);
|
||||
ctx.dom.mediaTimingReviewConfirm.addEventListener('click', () => confirmSelection());
|
||||
}
|
||||
|
||||
return {
|
||||
openMediaTimingReviewModal,
|
||||
handlePreviewEnded,
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user