Skip to content

Commit 0183f44

Browse files
committed
fix(editor): prevent transport button desync and boundary pause during replay
When replaying a timeline from the end boundary, starting playback could trigger spurious pause events before the rewind seek completes, causing the transport play/pause icon to momentarily flip from play to pause and back to play (leaving the UI desynchronized from actual playback). 1. In VirtualPreview, guard the playback rAF tick against checking clip boundaries and kept segments while the video element is seeking or has a programmatic seek in flight, and clear the programmatic seek flag upon seeked. 2. In NewEditorShell onEnded, verify that the playhead is genuinely at the timeline end before marking playback as stopped. 3. In NewEditorShell togglePlay, rewind the playhead to 0 before starting playback when stopped at or past the timeline duration.
1 parent 9fb4db2 commit 0183f44

3 files changed

Lines changed: 58 additions & 18 deletions

File tree

‎src/components/ai-edition/NewEditorShell.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
trimAppliesToClip,
5050
ventilateTimelineSpanToTrims,
5151
} from "@/lib/ai-edition/timeline/trim-mapping";
52+
import { totalVirtualDuration } from "@/lib/ai-edition/timeline/virtual-preview";
5253
import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status";
5354
import { matchesShortcut } from "@/lib/shortcuts";
5455
import { nativeBridgeClient } from "@/native";
@@ -615,8 +616,10 @@ export function NewEditorShell() {
615616
// vérité pour "y a-t-il encore de la timeline à jouer".
616617
const onEnded = () => {
617618
const playhead = useProjectStore.getState().currentTimeSec;
619+
const duration = totalVirtualDuration(clipsForEndedRef.current);
620+
const isAtEnd = duration > 0 && playhead >= duration - 0.1;
618621
const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1);
619-
if (!hasNextClip) setPlaying(false);
622+
if (isAtEnd && !hasNextClip) setPlaying(false);
620623
};
621624
el.addEventListener("play", onPlay);
622625
el.addEventListener("pause", onPause);
@@ -634,6 +637,11 @@ export function NewEditorShell() {
634637
const togglePlay = useCallback(() => {
635638
if (!videoElement) return;
636639
if (videoElement.paused) {
640+
const playhead = useProjectStore.getState().currentTimeSec;
641+
const duration = totalVirtualDuration(clipsForEndedRef.current);
642+
if (duration > 0 && playhead >= duration) {
643+
handleSeek(0);
644+
}
637645
// Same catch as VirtualPreview's: `play()` rejects on the autoplay policy
638646
// or when a new load interrupts it, and the store's `playing` flag is
639647
// driven by the element's own play/pause listeners above — so a rejection
@@ -644,7 +652,7 @@ export function NewEditorShell() {
644652
} else {
645653
videoElement.pause();
646654
}
647-
}, [videoElement]);
655+
}, [videoElement, handleSeek]);
648656

649657
// "Transcribe now" from the transcript pane. The run itself belongs to the
650658
// transcription store (it owns the queue, the toasts and the failure

‎src/components/ai-edition/VirtualPreview.playback.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,4 +790,35 @@ describe("VirtualPreview replay", () => {
790790
expect(video.currentTime).toBe(0);
791791
expect(element.paused).toBe(false);
792792
});
793+
794+
it("does not pause or cancel playback while a replay seek is in flight", () => {
795+
const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 2, 4, 0)]);
796+
video.play();
797+
video.seekTo(3.96);
798+
tick();
799+
tick();
800+
expect(element.paused).toBe(true);
801+
const initialPauseCalls = video.pauseCalls.length;
802+
803+
let seeking = true;
804+
Object.defineProperty(element, "seeking", { configurable: true, get: () => seeking });
805+
806+
act(() => {
807+
video.play();
808+
fireEvent.play(element);
809+
});
810+
// While seek is in flight across ticks, boundary check must not pause:
811+
tick();
812+
expect(element.paused).toBe(false);
813+
expect(video.pauseCalls.length).toBe(initialPauseCalls);
814+
815+
// Demuxer finishes seeking to target:
816+
seeking = false;
817+
act(() => fireEvent.seeked(element));
818+
tick();
819+
expect(element.paused).toBe(false);
820+
expect(video.pauseCalls.length).toBe(initialPauseCalls);
821+
expect(video.currentTime).toBe(2);
822+
expect(reportedTimes(onTimeChange).at(-1)).toBe(0);
823+
});
793824
});

‎src/components/ai-edition/VirtualPreview.tsx‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,10 +1171,25 @@ export function VirtualPreview({
11711171
// position rather than jumping. Note this cannot ride on `v.paused`,
11721172
// the gate the rest of the tick uses — an `error` does not fire
11731173
// `pause`, so a failure mid-playback leaves `paused` false.
1174-
if (recoveringRef.current) {
1174+
if (recoveringRef.current || v.seeking) {
11751175
return;
11761176
}
11771177
const activeSourceId = videoSourcesRef.current[sourceIndexRef.current]?.id;
1178+
if (isProgrammaticSeekRef.current) {
1179+
isProgrammaticSeekRef.current = false;
1180+
const pos = locateSourcePosition(
1181+
clipsRef.current,
1182+
v.currentTime,
1183+
activeSourceId,
1184+
0.05,
1185+
activeClipIdRef.current ?? undefined,
1186+
);
1187+
if (pos) {
1188+
activeClipIdRef.current = pos.clip.id;
1189+
updateVirtualTime(clampVirtualTime(clipsRef.current, pos.virtualTimeSec));
1190+
}
1191+
return;
1192+
}
11781193
// Trims only trim ahead during actual playback — scrubbing/paused seeks are
11791194
// intentionally NOT clamped, so the user can navigate freely into a trim while
11801195
// editing; only Play/export treats it as a cut (mirrors the pre-existing intent
@@ -1290,21 +1305,6 @@ export function VirtualPreview({
12901305
updateVirtualTime(v.currentTime);
12911306
return;
12921307
}
1293-
if (isProgrammaticSeekRef.current) {
1294-
isProgrammaticSeekRef.current = false;
1295-
const pos = locateSourcePosition(
1296-
clipsRef.current,
1297-
v.currentTime,
1298-
activeSourceId,
1299-
0.05,
1300-
activeClipIdRef.current ?? undefined,
1301-
);
1302-
if (pos) {
1303-
activeClipIdRef.current = pos.clip.id;
1304-
updateVirtualTime(clampVirtualTime(clipsRef.current, pos.virtualTimeSec));
1305-
}
1306-
return;
1307-
}
13081308
const position = locateSourcePosition(
13091309
clipsRef.current,
13101310
v.currentTime,
@@ -1739,6 +1739,7 @@ export function VirtualPreview({
17391739
}
17401740
}}
17411741
onSeeked={(e) => {
1742+
isProgrammaticSeekRef.current = false;
17421743
// The demuxer is free again: apply the newest target that
17431744
// arrived while it was busy, if the playhead has moved on.
17441745
const queued = pendingScrubTargetRef.current;

0 commit comments

Comments
 (0)