Files
SubMiner/src/renderer/modals/media-timing-review.ts
T
sudacode 144db675e9 fix(anki): close the timing review modal once main has dropped the review
Main ends a review on its own after the decision watchdog, on overlay
teardown, or when the modal window opened twice, but the renderer only
closed on a successful resolve. Every button then answered 'no longer
active' forever and the overlay stayed interactive over mpv. Stale
results now carry a typed flag and the modal closes on it.
2026-09-03 22:51:22 -07:00

961 lines
36 KiB
TypeScript

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,
};
}