From 9644513437d227b92f2144f7fd8260f6e69e42bf Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Tue, 27 Oct 2026 12:23:15 +0800 Subject: [PATCH 1/2] fix(editor): restart playback from the timeline beginning when ended When preview playback reaches the end of the timeline, pressing play again could stall, desync, or require multiple clicks to replay because the playhead was held at or past the timeline boundary, causing the playback loop to pause immediately upon starting. When the timeline was trimmed shorter than the underlying media file, clicking play repeatedly would continually hit the trim boundary without restarting from the beginning. 1. In NewEditorShell togglePlay, when playhead is at or near the timeline end (duration - 0.05s), pre-seek the video element synchronously to the start position before calling play(), ensuring playback starts cleanly from 0 without triggering an immediate boundary pause. 2. In NewEditorShell, synchronize onPlay and onPause to !el.paused, and guard onEnded with if (!el.paused) return so in-flight playback is never falsely marked as stopped. 3. In VirtualPreview onPlay, if virtualTime is at or near virtualDuration, rewind to the beginning via seekToVirtualTime(0, true, true). In onEnded, publish the timeline duration when no next clip exists, and guard against non-paused events. 4. Derive virtual duration from maximum timelineEndSec across clips rather than array index order, and restrict timeline endpoint candidates to positive-duration clips. --- src/components/ai-edition/NewEditorShell.tsx | 29 +++++++-- .../VirtualPreview.playback.test.tsx | 65 +++++++++++++++++++ src/components/ai-edition/VirtualPreview.tsx | 52 ++++++++++----- .../timeline/virtual-preview.test.ts | 27 ++++++++ .../ai-edition/timeline/virtual-preview.ts | 13 +++- 5 files changed, 162 insertions(+), 24 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index d87a215eaa..7c98bf86db 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -49,6 +49,10 @@ import { trimAppliesToClip, ventilateTimelineSpanToTrims, } from "@/lib/ai-edition/timeline/trim-mapping"; +import { + locateVirtualPosition, + totalVirtualDuration, +} from "@/lib/ai-edition/timeline/virtual-preview"; import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status"; import { matchesShortcut } from "@/lib/shortcuts"; import { nativeBridgeClient } from "@/native"; @@ -602,8 +606,8 @@ export function NewEditorShell() { useEffect(() => { const el = videoElement; if (!el) return; - const onPlay = () => setPlaying(true); - const onPause = () => setPlaying(false); + const onPlay = () => setPlaying(!el.paused); + const onPause = () => setPlaying(!el.paused); // BUG corrigé : ce listener écoutait le même événement DOM natif 'ended' que le // onEnded interne de VirtualPreview.tsx, sans la moindre logique multi-clip — il // mettait TOUJOURS `playing` à false, y compris quand VirtualPreview venait @@ -614,9 +618,12 @@ export function NewEditorShell() { // suivant ?" que VirtualPreview — seul point de // vérité pour "y a-t-il encore de la timeline à jouer". const onEnded = () => { + if (!el.paused) return; const playhead = useProjectStore.getState().currentTimeSec; + const duration = totalVirtualDuration(clipsForEndedRef.current); + const isAtEnd = duration > 0 && playhead >= duration - 0.1; const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1); - if (!hasNextClip) setPlaying(false); + if (isAtEnd && !hasNextClip) setPlaying(false); }; el.addEventListener("play", onPlay); el.addEventListener("pause", onPause); @@ -634,6 +641,20 @@ export function NewEditorShell() { const togglePlay = useCallback(() => { if (!videoElement) return; if (videoElement.paused) { + const playhead = useProjectStore.getState().currentTimeSec; + const duration = totalVirtualDuration(clipsForEndedRef.current); + if (duration > 0 && playhead >= duration - 0.05) { + handleSeek(0); + const startPos = locateVirtualPosition(clipsForEndedRef.current, 0); + const endPos = locateVirtualPosition(clipsForEndedRef.current, duration); + if (startPos && startPos.clip.assetId === endPos?.clip.assetId) { + try { + videoElement.currentTime = startPos.sourceTimeSec; + } catch { + // media metadata not ready yet + } + } + } // Same catch as VirtualPreview's: `play()` rejects on the autoplay policy // or when a new load interrupts it, and the store's `playing` flag is // driven by the element's own play/pause listeners above — so a rejection @@ -644,7 +665,7 @@ export function NewEditorShell() { } else { videoElement.pause(); } - }, [videoElement]); + }, [videoElement, handleSeek]); // "Transcribe now" from the transcript pane. The run itself belongs to the // transcription store (it owns the queue, the toasts and the failure diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx index becfb077a4..4a8ffc4fb8 100644 --- a/src/components/ai-edition/VirtualPreview.playback.test.tsx +++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx @@ -757,3 +757,68 @@ describe("VirtualPreview primary audio keeps to the picture", () => { expect(world.audioEl.playbackRate).toBe(1); }); }); + +describe("VirtualPreview replay", () => { + it.each([10, 4])("restarts with one play event after a %s-second clip ends", (end) => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 2, end, 0)]); + video.play(); + video.seekTo(end - 0.04); + tick(); + tick(); + expect(element.paused).toBe(true); + + act(() => { + video.play(); + fireEvent.play(element); + }); + tick(); + expect(video.currentTime).toBe(2); + expect(element.paused).toBe(false); + expect(reportedTimes(onTimeChange).at(-1)).toBe(0); + }); + + it("restarts after the browser's ended event", () => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 0, 10, 0)]); + video.seekTo(10); + act(() => fireEvent.ended(element)); + expect(reportedTimes(onTimeChange).at(-1)).toBe(10); + act(() => { + video.play(); + fireEvent.play(element); + }); + tick(); + expect(video.currentTime).toBe(0); + expect(element.paused).toBe(false); + }); + + it("does not pause or cancel playback while a replay seek is in flight", () => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 2, 4, 0)]); + video.play(); + video.seekTo(3.96); + tick(); + tick(); + expect(element.paused).toBe(true); + const initialPauseCalls = video.pauseCalls.length; + + let seeking = true; + Object.defineProperty(element, "seeking", { configurable: true, get: () => seeking }); + + act(() => { + video.play(); + fireEvent.play(element); + }); + // While seek is in flight across ticks, boundary check must not pause: + tick(); + expect(element.paused).toBe(false); + expect(video.pauseCalls.length).toBe(initialPauseCalls); + + // Demuxer finishes seeking to target: + seeking = false; + act(() => fireEvent.seeked(element)); + tick(); + expect(element.paused).toBe(false); + expect(video.pauseCalls.length).toBe(initialPauseCalls); + expect(video.currentTime).toBe(2); + expect(reportedTimes(onTimeChange).at(-1)).toBe(0); + }); +}); diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx index 3481e23687..481b0a6904 100644 --- a/src/components/ai-edition/VirtualPreview.tsx +++ b/src/components/ai-edition/VirtualPreview.tsx @@ -1171,10 +1171,25 @@ export function VirtualPreview({ // position rather than jumping. Note this cannot ride on `v.paused`, // the gate the rest of the tick uses — an `error` does not fire // `pause`, so a failure mid-playback leaves `paused` false. - if (recoveringRef.current) { + if (recoveringRef.current || v.seeking) { return; } const activeSourceId = videoSourcesRef.current[sourceIndexRef.current]?.id; + if (isProgrammaticSeekRef.current) { + isProgrammaticSeekRef.current = false; + const pos = locateSourcePosition( + clipsRef.current, + v.currentTime, + activeSourceId, + 0.05, + activeClipIdRef.current ?? undefined, + ); + if (pos) { + activeClipIdRef.current = pos.clip.id; + updateVirtualTime(clampVirtualTime(clipsRef.current, pos.virtualTimeSec)); + } + return; + } // Trims only trim ahead during actual playback — scrubbing/paused seeks are // intentionally NOT clamped, so the user can navigate freely into a trim while // editing; only Play/export treats it as a cut (mirrors the pre-existing intent @@ -1290,21 +1305,6 @@ export function VirtualPreview({ updateVirtualTime(v.currentTime); return; } - if (isProgrammaticSeekRef.current) { - isProgrammaticSeekRef.current = false; - const pos = locateSourcePosition( - clipsRef.current, - v.currentTime, - activeSourceId, - 0.05, - activeClipIdRef.current ?? undefined, - ); - if (pos) { - activeClipIdRef.current = pos.clip.id; - updateVirtualTime(clampVirtualTime(clipsRef.current, pos.virtualTimeSec)); - } - return; - } const position = locateSourcePosition( clipsRef.current, v.currentTime, @@ -1739,6 +1739,7 @@ export function VirtualPreview({ } }} onSeeked={(e) => { + isProgrammaticSeekRef.current = false; // The demuxer is free again: apply the newest target that // arrived while it was busy, if the playhead has moved on. const queued = pendingScrubTargetRef.current; @@ -1818,7 +1819,20 @@ export function VirtualPreview({ el.pause(); onVideoError?.(activeSource.id, detail); }} - onEnded={() => { + onPlay={() => { + // The edited timeline can end before the underlying file. A + // plain play() would hit that same boundary and pause again. + // Resolve the rewind here so cross-asset restarts also carry + // playback intent through the pending metadata load. + if ( + virtualDurationSecRef.current > 0 && + virtualTimeSecRef.current >= virtualDurationSecRef.current - 0.05 + ) { + seekToVirtualTimeRef.current?.(0, true, true); + } + }} + onEnded={(e) => { + if (!e.currentTarget.paused) return; // BUG corrigé : ce handler stoppait TOUJOURS la lecture dès que le //