fix(overlay): drive timing review playhead from mpv playback position

- Preview session observes time-pos, waits for loaded media before seeking, and acks each command
- Forward positions to the modal over media-timing-review:preview-position
- Cursor follows the real position, pauses while buffering, and stays at the clip end when done
- Replace the fixed end-of-clip timer with a 15s stall timeout
- Document preview cursor behavior and ownership in docs
This commit is contained in:
2026-09-29 18:07:07 -07:00
parent 04b878dd93
commit fdb0a31fff
14 changed files with 335 additions and 57 deletions
+36 -15
View File
@@ -3,6 +3,7 @@ import type {
MediaTimingReviewContextLine,
MediaTimingReviewDecision,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
} from '../../types/anki';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
@@ -20,8 +21,8 @@ const LINE_REVEAL_MARGIN_SECONDS = 1;
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;
/** Allow buffering and output-device delays without cutting off an advancing preview. */
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
@@ -303,30 +304,49 @@ export function createMediaTimingReviewModal(
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 {
function armPreviewTimeout(): void {
clearPreviewTimer();
previewTimer = setTimeout(() => {
stopPreview();
setStatus('Audio preview stopped responding. Try playing the selection again.', true);
}, PREVIEW_STALL_TIMEOUT_MS);
}
/** Playback position comes from mpv, so stalls and output latency cannot outrun the cursor. */
function setPreviewPlaying(playing: boolean, finished = false): 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');
track.classList.toggle('is-previewing', playing || finished);
if (finished) track.style.setProperty('--playhead-position', 'var(--selection-end)');
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);
track.style.setProperty('--playhead-position', 'var(--selection-start)');
armPreviewTimeout();
}
function handlePreviewPosition(position: MediaTimingReviewPreviewPosition): void {
if (
!payload ||
payload.reviewId !== position.reviewId ||
!previewPlaying ||
!Number.isFinite(position.time)
)
return;
const time = clamp(position.time, selectionStart, selectionEnd);
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
'--playhead-position',
`${clamp(((time - timelineStart) / span) * 100, 0, 100)}%`,
);
armPreviewTimeout();
}
/** 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);
setPreviewPlaying(false, true);
setStatus('');
}
@@ -1026,6 +1046,7 @@ export function createMediaTimingReviewModal(
return {
openMediaTimingReviewModal,
handlePreviewEnded,
handlePreviewPosition,
requestCancel,
handleMediaTimingReviewKeydown,
wireDomEvents,
+3
View File
@@ -625,6 +625,9 @@ function registerModalOpenHandlers(): void {
mediaTimingReviewModal.handlePreviewEnded(reviewId);
});
});
window.electronAPI.onMediaTimingReviewPreviewPosition((position) => {
mediaTimingReviewModal.handlePreviewPosition(position);
});
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
+4 -15
View File
@@ -1687,7 +1687,7 @@ body:focus-visible,
--selection-end: 80%;
--original-start: 25%;
--original-end: 75%;
--playhead-duration: 1s;
--playhead-position: var(--selection-start);
position: relative;
height: 52px;
@@ -1803,10 +1803,10 @@ body:focus-visible,
.media-timing-review-playhead {
position: absolute;
z-index: 5;
z-index: 7;
top: 0;
bottom: 0;
left: var(--selection-start);
left: clamp(0px, var(--playhead-position), calc(100% - 2px));
width: 2px;
background: var(--ctp-yellow);
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
@@ -1816,16 +1816,6 @@ body:focus-visible,
.media-timing-review-track.is-previewing .media-timing-review-playhead {
opacity: 1;
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
}
@keyframes media-timing-review-playhead {
from {
left: var(--selection-start);
}
to {
left: var(--selection-end);
}
}
/* A thin line centered on the clip edge, so the line itself is the trim boundary. */
@@ -2128,8 +2118,7 @@ body:focus-visible,
animation: none;
}
.media-timing-review-track.is-loading::after,
.media-timing-review-track.is-previewing .media-timing-review-playhead {
.media-timing-review-track.is-loading::after {
animation: none;
}
}