diff --git a/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx b/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx
new file mode 100644
index 000000000..3fa5fcb73
--- /dev/null
+++ b/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx
@@ -0,0 +1,222 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom";
+import { act, cleanup, fireEvent, render } from "@testing-library/react";
+import { createElement, useEffect } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ previewPlay: vi.fn(() => Promise.resolve()),
+ previewPause: vi.fn(),
+ latestSeekTarget: null as {
+ timeSec: number;
+ isSource?: boolean;
+ requestId: number;
+ preservePlayback?: boolean;
+ } | null,
+}));
+
+vi.mock("@/contexts/ShortcutsContext", async () => {
+ const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
+ return {
+ useShortcuts: () => ({
+ shortcuts: DEFAULT_SHORTCUTS,
+ isMac: false,
+ isConfigOpen: false,
+ openConfig: vi.fn(),
+ closeConfig: vi.fn(),
+ setShortcuts: vi.fn(),
+ persistShortcuts: () => Promise.resolve(true),
+ }),
+ };
+});
+
+vi.mock("@/contexts/I18nContext", () => ({
+ useI18n: () => ({
+ locale: "en",
+ setLocale: vi.fn(),
+ }),
+ useScopedT: () => (key: string) => key,
+}));
+
+vi.mock("./Preview", () => ({
+ Preview: ({
+ seekTarget,
+ onVideoElement,
+ }: {
+ seekTarget: {
+ timeSec: number;
+ isSource?: boolean;
+ requestId: number;
+ preservePlayback?: boolean;
+ } | null;
+ onVideoElement: (element: HTMLVideoElement | null) => void;
+ }) => {
+ mocks.latestSeekTarget = seekTarget;
+ useEffect(() => {
+ const element = document.createElement("video");
+ let paused = true;
+ Object.defineProperty(element, "paused", {
+ configurable: true,
+ get: () => paused,
+ });
+ element.play = vi.fn(() => {
+ paused = false;
+ element.dispatchEvent(new Event("play"));
+ return mocks.previewPlay();
+ });
+ element.pause = vi.fn(() => {
+ paused = true;
+ element.dispatchEvent(new Event("pause"));
+ mocks.previewPause();
+ });
+ onVideoElement(element);
+ return () => onVideoElement(null);
+ }, [onVideoElement]);
+ return createElement("div", {
+ "data-testid": "preview-mock",
+ "data-seek-target": JSON.stringify(seekTarget),
+ });
+ },
+}));
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
+import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
+import { createEmptyDocument } from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { NewEditorShell } from "./NewEditorShell";
+
+function makeCrossAssetDocument(): AxcutDocument {
+ const doc = createEmptyDocument({ projectId: "p1", title: "Cross-Asset Replay" });
+ const asset1: AxcutAsset = {
+ id: "a1",
+ kind: "video",
+ label: "a1",
+ originalPath: "/a1.mp4",
+ durationSec: 5,
+ cameraTrack: null,
+ };
+ const asset2: AxcutAsset = {
+ id: "a2",
+ kind: "video",
+ label: "a2",
+ originalPath: "/a2.mp4",
+ durationSec: 5,
+ cameraTrack: null,
+ };
+ const clip1: AxcutClip = {
+ id: "c1",
+ assetId: "a1",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 0,
+ timelineEndSec: 5,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+ const clip2: AxcutClip = {
+ id: "c2",
+ assetId: "a2",
+ sourceStartSec: 0,
+ sourceEndSec: 5,
+ timelineStartSec: 5,
+ timelineEndSec: 10,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ };
+ doc.assets = [asset1, asset2];
+ doc.timeline.clips = [clip1, clip2];
+ return doc;
+}
+
+function renderShell() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("NewEditorShell togglePlay cross-asset replay", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ mocks.previewPlay.mockClear();
+ mocks.previewPause.mockClear();
+ mocks.latestSeekTarget = null;
+ (window as unknown as { electronAPI?: unknown }).electronAPI = {
+ onAiEditionChatEvent: () => () => {},
+ setTitleBarOverlay: () => {},
+ setHasUnsavedChanges: () => {},
+ onRequestCloseConfirm: () => () => {},
+ onRequestSaveBeforeClose: () => () => {},
+ sendCloseConfirmResponse: () => {},
+ findRecordingCamera: () => Promise.resolve(null),
+ preparePreviewAudioTrack: () => Promise.resolve({ success: false, path: null }),
+ readBinaryFile: () => Promise.resolve(new Uint8Array(0)),
+ isAppPackaged: () => false,
+ };
+ Element.prototype.scrollTo = () => {};
+ (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+ });
+
+ afterEach(() => {
+ cleanup();
+ (window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
+ });
+
+ it("preserves playback intent when replaying across assets through togglePlay", async () => {
+ const doc = makeCrossAssetDocument();
+ useProjectStore.setState({
+ document: doc,
+ projectId: "p1",
+ currentTimeSec: 10, // at the end of the timeline
+ playing: false,
+ });
+
+ renderShell();
+
+ // Trigger replay via Space shortcut (togglePlay)
+ await act(async () => {
+ fireEvent.keyDown(document.body, { key: " ", code: "Space" });
+ });
+
+ // 1. togglePlay must have initiated a seek to 0 carrying preservePlayback: true
+ expect(mocks.latestSeekTarget).toMatchObject({
+ timeSec: 0,
+ preservePlayback: true,
+ });
+
+ // 2. togglePlay must have called play() to start playback
+ expect(mocks.previewPlay).toHaveBeenCalledTimes(1);
+ expect(useProjectStore.getState().currentTimeSec).toBe(0);
+ });
+
+ it("does not rewind to 0 when pressing play from an intermediate position after cross-asset seek", async () => {
+ const doc = makeCrossAssetDocument();
+ useProjectStore.setState({
+ document: doc,
+ projectId: "p1",
+ currentTimeSec: 4, // in clip1 (0-5s), timeline ends at 10s
+ playing: false,
+ });
+
+ renderShell();
+
+ await act(async () => {
+ fireEvent.keyDown(document.body, { key: " ", code: "Space" });
+ });
+
+ // Must NOT seek to 0
+ expect(mocks.latestSeekTarget).toBeNull();
+ expect(mocks.previewPlay).toHaveBeenCalledTimes(1);
+ expect(useProjectStore.getState().currentTimeSec).toBe(4);
+ });
+});
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index d87a215ea..b3340ce85 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";
@@ -81,6 +85,7 @@ interface SeekTarget {
timeSec: number;
isSource?: boolean;
requestId: number;
+ preservePlayback?: boolean;
}
/**
@@ -536,9 +541,14 @@ export function NewEditorShell() {
);
const handleSeek = useCallback(
- (timeSec: number) => {
+ (timeSec: number, preservePlayback?: boolean) => {
setCurrentTime(timeSec);
- setSeekTarget({ timeSec, isSource: false, requestId: ++seekSeqRef.current });
+ setSeekTarget({
+ timeSec,
+ isSource: false,
+ requestId: ++seekSeqRef.current,
+ preservePlayback,
+ });
},
[setCurrentTime],
);
@@ -602,8 +612,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 +624,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 +647,27 @@ export function NewEditorShell() {
const togglePlay = useCallback(() => {
if (!videoElement) return;
if (videoElement.paused) {
+ const playhead = useProjectStore.getState().currentTimeSec;
+ const duration = totalVirtualDuration(clipsForEndedRef.current);
+ const endPos = locateVirtualPosition(clipsForEndedRef.current, duration);
+ const currentPos = locateVirtualPosition(clipsForEndedRef.current, playhead);
+ const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1);
+ const isAtEnd =
+ duration > 0 &&
+ playhead >= duration - 0.05 &&
+ !hasNextClip &&
+ currentPos?.clip.id === endPos?.clip.id;
+ if (isAtEnd) {
+ handleSeek(0, true);
+ const startPos = locateVirtualPosition(clipsForEndedRef.current, 0);
+ 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 +678,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/Preview.tsx b/src/components/ai-edition/Preview.tsx
index c809a329b..2d64b4428 100644
--- a/src/components/ai-edition/Preview.tsx
+++ b/src/components/ai-edition/Preview.tsx
@@ -50,7 +50,7 @@ interface PreviewProps {
onAnnotationChange?: (id: string, patch: Partial) => void;
onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void;
onAnnotationCommit?: () => void;
- seekTarget: { timeSec: number; requestId: number } | null;
+ seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null;
onTimeChange: (sec: number) => void;
onSeek: (sec: number) => void;
onLoadedMetadata: (sec: number, assetId: string) => void;
diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx
index 557593f4c..5ba8f0007 100644
--- a/src/components/ai-edition/PreviewCanvas.tsx
+++ b/src/components/ai-edition/PreviewCanvas.tsx
@@ -100,7 +100,7 @@ interface PreviewCanvasProps {
onAnnotationChange?: (id: string, patch: Partial) => void;
onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void;
onAnnotationCommit?: () => void;
- seekTarget: { timeSec: number; requestId: number } | null;
+ seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null;
onTimeChange: (sec: number) => void;
onSeek: (sec: number) => void;
onLoadedMetadata: (sec: number, assetId: string) => void;
diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx
index becfb077a..517d0b9b6 100644
--- a/src/components/ai-edition/VirtualPreview.playback.test.tsx
+++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx
@@ -93,6 +93,9 @@ function driveVideo(element: HTMLVideoElement) {
play: () => {
paused = false;
},
+ pause: () => {
+ paused = true;
+ },
seekTo: (next: number) => {
currentTime = next;
},
@@ -757,3 +760,534 @@ 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);
+ });
+
+ it("restarts playback across assets when the last clip uses a different asset than the first", () => {
+ const sources: VideoSource[] = [
+ { id: "a1", src: "file:///tmp/a1.mp4", label: "a1" },
+ { id: "a2", src: "file:///tmp/a2.mp4", label: "a2" },
+ ];
+ const clips = [clip("clip_1", "a1", 0, 5, 0), clip("clip_2", "a2", 0, 5, 5)];
+ let currentSeekTarget: {
+ timeSec: number;
+ requestId: number;
+ preservePlayback?: boolean;
+ } | null = null;
+ const onTimeChange = vi.fn();
+
+ const view = render(
+ ,
+ );
+
+ // Mount first asset (a1)
+ const videoEl1 = view.container.querySelector("video");
+ if (!videoEl1) throw new Error("no initial