Skip to content

Commit faa3f58

Browse files
fix(runtime): don't restart non-loop media that has naturally ended (#1203)
* fix(runtime): don't restart non-loop media that has naturally ended When a media element's authored data-duration exceeds the actual file length, el.ended becomes true at the file's natural end while the clip is still considered 'active' (timeSeconds < clip.end). The runtime was calling el.play() on the ended element every rAF tick, resetting currentTime to 0 and causing audible stutter for the overshoot duration. Fix: treat el.ended as inactive for non-loop clips. The element sits silently until the composition ends. el.ended resets to false on any seek, so scrubbing backward correctly resumes playback. Reproducer: bg-music WAV is 60s but data-duration='68.6' (composition duration). Last 8.6s: rapid play->clamp->end->play cycle at 60fps. * test(runtime): add seek-recovery contract test for el.ended guard Adds a third test case pinning the seek-recovery property called out in the PR body: a clip that went silent at t=62 (el.ended=true) should resume playing after a backward seek resets el.ended to false.
1 parent 9679503 commit faa3f58

2 files changed

Lines changed: 41 additions & 1 deletion

File tree

packages/core/src/runtime/media.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,39 @@ describe("syncRuntimeMedia", () => {
320320
expect(clip.el.pause).toHaveBeenCalled();
321321
});
322322

323+
it("does not restart a non-loop clip that has naturally ended before the clip's end time", () => {
324+
// Reproduces: bg-music WAV is 60s but data-duration="68.6" (composition duration).
325+
// At t=62 the file has ended; without this guard the runtime calls el.play() every
326+
// rAF tick, resetting currentTime to 0 and causing audible stutter for 8.6s.
327+
const clip = createMockClip({ start: 0, end: 68.6, loop: false });
328+
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
329+
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
330+
syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 });
331+
expect(clip.el.play).not.toHaveBeenCalled();
332+
});
333+
334+
it("does restart a loop clip that has naturally ended while still within its active window", () => {
335+
const clip = createMockClip({ start: 0, end: 68.6, loop: true, sourceDuration: 60 });
336+
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
337+
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
338+
syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 });
339+
expect(clip.el.play).toHaveBeenCalled();
340+
});
341+
342+
it("resumes a previously-ended non-loop clip after a backward seek resets el.ended", () => {
343+
// el.ended resets to false when the browser processes a seek (per WHATWG spec).
344+
// This test pins the contract: silent at t=62 (ended), playable again at t=30 (seeked back).
345+
const clip = createMockClip({ start: 0, end: 68.6, loop: false });
346+
Object.defineProperty(clip.el, "paused", { value: true, writable: true });
347+
Object.defineProperty(clip.el, "ended", { value: true, writable: true, configurable: true });
348+
syncRuntimeMedia({ clips: [clip], timeSeconds: 62, playing: true, playbackRate: 1 });
349+
expect(clip.el.play).not.toHaveBeenCalled();
350+
// Simulate backward seek: browser resets ended to false before committing new currentTime
351+
Object.defineProperty(clip.el, "ended", { value: false, writable: true, configurable: true });
352+
syncRuntimeMedia({ clips: [clip], timeSeconds: 30, playing: true, playbackRate: 1 });
353+
expect(clip.el.play).toHaveBeenCalledTimes(1);
354+
});
355+
323356
it("sets volume when clip has volume", () => {
324357
const clip = createMockClip({ start: 0, end: 10, volume: 0.7 });
325358
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: false, playbackRate: 1 });

packages/core/src/runtime/media.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,15 @@ export function syncRuntimeMedia(params: {
159159
const { el } = clip;
160160
if (!el.isConnected) continue;
161161
let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart;
162+
// An ended non-loop element has played its file to natural completion.
163+
// Don't restart it — if the authored duration extends past the file's
164+
// actual length, the element sits silently until the composition ends.
165+
// (el.ended resets to false when the user scrubs back, so seeks work.)
162166
const isActive =
163-
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
167+
params.timeSeconds >= clip.start &&
168+
params.timeSeconds < clip.end &&
169+
relTime >= 0 &&
170+
(!el.ended || clip.loop);
164171
if (isActive) {
165172
// Loop wrapping: when media reaches end, restart from mediaStart
166173
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {

0 commit comments

Comments
 (0)