From df0002f457648496abd4bc52ea4f6769ab02c312 Mon Sep 17 00:00:00 2001 From: "Alexander C.S.L" Date: Tue, 29 Sep 2026 08:01:50 +0700 Subject: [PATCH] Resume preview audio after source seeks --- .../audio/useAudioPreviewSync.test.ts | 13 +++++-- .../video-editor/audio/useAudioPreviewSync.ts | 17 ++++++--- tests/ui/clip-cut-delete.spec.ts | 35 ++++++++++++++----- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/components/video-editor/audio/useAudioPreviewSync.test.ts b/src/components/video-editor/audio/useAudioPreviewSync.test.ts index a1a2dcff..0d483889 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.test.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.test.ts @@ -119,6 +119,7 @@ describe("source preview playback ownership", () => { seeking, wasPlaying, }) => { + const seekedListeners = new Set<() => void>(); const audio = { src: "", dataset: {}, @@ -136,6 +137,12 @@ describe("source preview playback ownership", () => { audio.paused = false; return Promise.resolve(); }), + addEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "seeked") seekedListeners.add(listener); + }), + removeEventListener: vi.fn((event: string, listener: () => void) => { + if (event === "seeked") seekedListeners.delete(listener); + }), }; vi.stubGlobal("Audio", function () { return audio; @@ -172,15 +179,17 @@ describe("source preview playback ownership", () => { expect(harness.loaded).toHaveBeenCalledOnce(); expect(audio.play).not.toHaveBeenCalled(); if (wasPlaying) audio.paused = false; - harness.effects.at(-1)?.(); + const cleanup = harness.effects.at(-1)?.(); await Promise.resolve(); expect(audio.play).toHaveBeenCalledTimes(plays ? 1 : 0); if (wasPlaying) expect(audio.pause).toHaveBeenCalled(); if (seeking) { audio.seeking = false; - harness.effects.at(-1)?.(); + for (const listener of seekedListeners) listener(); await Promise.resolve(); expect(audio.play).toHaveBeenCalledOnce(); + if (typeof cleanup === "function") cleanup(); + expect(seekedListeners.size).toBe(0); } if (plays) { expect(audio.currentTime).toBeCloseTo(time - delay / 1000); diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts index aacbafda..d3febb6e 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -377,6 +377,7 @@ export function useAudioPreviewSync({ return; } let cancelled = false; + const seekedListeners: Array<{ audio: HTMLAudioElement; resume: () => void }> = []; // A newly resolved source must pass the same playback checks as timeline updates. void sourceLoadVersion; @@ -453,10 +454,15 @@ export function useAudioPreviewSync({ const atEnd = audioDuration !== null && targetTime >= audioDuration; if (isPlaying && !isCurrentClipMuted && !beforeAudioStart && !atEnd) { - void ensureSourceAudioRunning().then(() => { - if (!cancelled && audio.paused && !audio.seeking) - audio.play().catch(() => undefined); - }); + const resume = () => { + void ensureSourceAudioRunning().then(() => { + if (!cancelled && audio.paused && !audio.seeking) + audio.play().catch(() => undefined); + }); + }; + audio.addEventListener("seeked", resume); + seekedListeners.push({ audio, resume }); + resume(); } else if (!audio.paused) { audio.pause(); } @@ -465,6 +471,9 @@ export function useAudioPreviewSync({ lastSourceAudioSyncTimeRef.current = currentTime; return () => { cancelled = true; + for (const { audio, resume } of seekedListeners) { + audio.removeEventListener("seeked", resume); + } }; }, [ sourceLoadVersion, diff --git a/tests/ui/clip-cut-delete.spec.ts b/tests/ui/clip-cut-delete.spec.ts index 8a258984..751a0d47 100644 --- a/tests/ui/clip-cut-delete.spec.ts +++ b/tests/ui/clip-cut-delete.spec.ts @@ -4,6 +4,7 @@ import { installDesktopBridge } from "./bridge"; test.use({ channel: process.env.RECORDLY_TEST_BROWSER_CHANNEL === "chrome" ? "chrome" : undefined }); test("two cuts select the middle clip for deletion", async ({ page }) => { + test.setTimeout(45_000); await installDesktopBridge(page, "filmstrip.mp4"); await page.goto("/?windowType=editor"); const clips = page.locator('[data-variant="clip"]'); @@ -13,24 +14,40 @@ test("two cuts select the middle clip for deletion", async ({ page }) => { const canvas = await timeline.boundingBox(); if (!firstClip || !canvas) throw new Error("Timeline is not visible"); - await page.mouse.click(firstClip.x + firstClip.width * 0.25, canvas.y + 6); + await page.mouse.click(firstClip.x + firstClip.width * 0.2, canvas.y + 6); await page.getByRole("button", { name: /Split Clip/ }).click(); await expect(clips).toHaveCount(2); - await page.mouse.click(firstClip.x + firstClip.width * 0.5, canvas.y + 6); + await page.mouse.click(firstClip.x + firstClip.width * 0.65, canvas.y + 6); await page.getByRole("button", { name: /Split Clip/ }).click(); await expect(clips).toHaveCount(3); + const beforeDelete = await clips.evaluateAll((elements) => + elements.map((element) => ({ + start: Number(element.getAttribute("data-start-ms")), + end: Number(element.getAttribute("data-end-ms")), + })), + ); + const [firstBefore, middleBefore, lastBefore] = beforeDelete; await page.keyboard.press("Delete"); await expect(clips).toHaveCount(2); - const firstEnd = Number(await clips.nth(0).getAttribute("data-end-ms")); - const lastStart = Number(await clips.nth(1).getAttribute("data-start-ms")); - expect(lastStart).toBe(firstEnd); + await expect(clips.first()).toHaveAttribute("data-start-ms", String(firstBefore.start)); + await expect(clips.first()).toHaveAttribute("data-end-ms", String(firstBefore.end)); + await expect(clips.last()).toHaveAttribute("data-start-ms", String(firstBefore.end)); + await expect(clips.last()).toHaveAttribute( + "data-end-ms", + String(firstBefore.end + lastBefore.end - lastBefore.start), + ); + expect(middleBefore.end - middleBefore.start).toBeGreaterThan(0); const lastClip = await clips.nth(1).boundingBox(); if (!lastClip) throw new Error("Retained clip is not visible"); await page.mouse.click(lastClip.x + Math.min(8, lastClip.width / 4), canvas.y + 6); + const video = page.locator('video[src*="filmstrip.mp4"]'); await expect - .poll(() => - page.locator('video[src*="filmstrip.mp4"]').evaluate((video: HTMLVideoElement) => video.currentTime), - ) - .toBeGreaterThan((firstEnd + 500) / 1_000); + .poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(middleBefore.end / 1000); + const timeBeforePlay = await video.evaluate((element: HTMLVideoElement) => element.currentTime); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await expect + .poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)) + .toBeGreaterThan(timeBeforePlay + 0.1); });