mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user