diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index 9860192bf..5755661ca 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -187,3 +187,24 @@ interface LottieWindow extends Window { /** Compositions register their Lottie animation instances here for the adapter to seek. */ __hfLottie?: Array; } + +/** + * Whether a registered Lottie animation has finished loading its JSON source. + * + * Handles both supported player shapes: + * - `lottie-web` exposes a boolean `isLoaded` property on `AnimationItem`. + * - `@dotlottie/player-component` doesn't have `isLoaded`; we infer readiness + * from `totalFrames > 0` since that value is only populated once the + * manifest/animation JSON has been parsed. + * + * Exported so consumers (e.g. the studio "Loading assets…" overlay) share a + * single source of truth instead of re-implementing the duck-typing per call + * site. + */ +export function isLottieAnimationLoaded(anim: unknown): boolean { + if (typeof anim !== "object" || anim === null) return true; // unknown shape — don't block + const maybe = anim as { isLoaded?: boolean; totalFrames?: number }; + if (maybe.isLoaded === true) return true; + if (typeof maybe.totalFrames === "number" && maybe.totalFrames > 0) return true; + return false; +} diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 0cc605e05..c052ee6b0 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -145,6 +145,17 @@ describe("refreshRuntimeMediaCache", () => { }); describe("syncRuntimeMedia", () => { + function fakePlayedRanges(el: HTMLMediaElement, ranges: Array<[number, number]>): void { + Object.defineProperty(el, "played", { + configurable: true, + get: () => ({ + length: ranges.length, + start: (i: number) => ranges[i][0], + end: (i: number) => ranges[i][1], + }), + }); + } + function createMockClip(overrides?: Partial): RuntimeMediaClip { const el = document.createElement("video") as HTMLVideoElement; document.body.appendChild(el); @@ -153,6 +164,9 @@ describe("syncRuntimeMedia", () => { el.pause = vi.fn(); Object.defineProperty(el, "currentTime", { value: 0, writable: true, configurable: true }); Object.defineProperty(el, "playbackRate", { value: 1, writable: true, configurable: true }); + // Default: audio has been playing — so drift-seek forward is allowed. + // Tests that exercise the "cold first play" guard call fakePlayedRanges(el, []). + fakePlayedRanges(el, [[0, 1]]); return { el, start: 0, @@ -178,35 +192,42 @@ describe("syncRuntimeMedia", () => { expect(clip.el.play).toHaveBeenCalled(); }); - it("defers play on unbuffered media and calls load()", () => { + it("plays synchronously even when media is unbuffered (preserves user gesture)", () => { + // Calling play() synchronously inside the user-gesture call chain lets the + // browser queue playback until data buffers, while consuming the transient + // user activation. Deferring to an async canplay handler would let the + // activation expire and the autoplay policy silently reject — producing + // the "silent first play, audio only after second click" bug. const clip = createMockClip({ start: 0, end: 10 }); Object.defineProperty(clip.el, "readyState", { value: 0, writable: true }); - const loadSpy = vi.spyOn(clip.el, "load").mockImplementation(() => {}); - const addEventSpy = vi.spyOn(clip.el, "addEventListener"); syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 }); - expect(clip.el.play).not.toHaveBeenCalled(); - expect(loadSpy).toHaveBeenCalledOnce(); - expect(addEventSpy).toHaveBeenCalledWith("canplay", expect.any(Function), { once: true }); + expect(clip.el.play).toHaveBeenCalled(); }); - it("plays when canplay fires after deferred play", () => { + it("nudges preload to auto AND calls play() on unbuffered media in one pass", () => { + // Assets added after the runtime already bound its metadata listeners + // (e.g. a sub-composition that injects a late