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; 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 | null = null; let waveformTimer: ReturnType | 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 { 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 { 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 { if (!payload || resolveInFlight) return; resolveInFlight = true; stopPreview(); const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll('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 { 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, }; }