Resume preview audio after source seeks

This commit is contained in:
Alexander C.S.L committed 2026-09-29 08:01:50 +07:00
1 parent c5117bc572
commit df0002f457
3 files changed
+50 -15

No files matched your search

@@ -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);
@@ -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,
+26 -9
View File
@@ -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);
});