diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c6c8d1a839e..40a4fe48b9f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -399,6 +399,7 @@ const MEDIA_URL_ATTRS = new Map([ ]); const SLOW_IDLE_HEARTBEAT_MS = 1000; +const HIDDEN_TRANSPORT_TICK_MS = 16; // One document.getAnimations() per seek and the pause after it, read on first use, shared by all adapters. function pageAnimationsForOnePass(): () => Animation[] { @@ -4783,13 +4784,45 @@ export function initSandboxRuntimeModular(): void { armParkTimer(); }; + // Hidden documents run no animation frames but their media plays on, so tick on a timer. + // Chromium clamps it to ~1 s unless audio is audible: a clip may end up to 1 s late. + let transportTickTimerId: number | null = null; + const transportTickPending = () => state.transportRafId != null || transportTickTimerId != null; + const requestTransportTick = () => { + if (document.visibilityState === "hidden") { + transportTickTimerId = window.setTimeout(() => { + transportTickTimerId = null; + transportTick(); + }, HIDDEN_TRANSPORT_TICK_MS); + return; + } + state.transportRafId = window.requestAnimationFrame(transportTick); + }; + const cancelTransportTick = () => { + if (state.transportRafId != null) { + window.cancelAnimationFrame(state.transportRafId); + state.transportRafId = null; + } + if (transportTickTimerId != null) { + window.clearTimeout(transportTickTimerId); + transportTickTimerId = null; + } + }; + // A frame requested just before the tab hid never fires: move a pending tick across. + const onTransportVisibilityChange = () => { + if (state.tornDown || !transportTickPending()) return; + cancelTransportTick(); + requestTransportTick(); + }; + document.addEventListener("visibilitychange", onTransportVisibilityChange); + const scheduleNextTransportFrame = () => { state.transportRafId = null; if (state.tornDown) return; const woken = transportWakeRequested; transportWakeRequested = false; if (woken || !canParkTransport()) { - state.transportRafId = window.requestAnimationFrame(transportTick); + requestTransportTick(); return; } // Read fresh rather than reusing the tick's own `timingRevision`: the tick @@ -4805,13 +4838,13 @@ export function initSandboxRuntimeModular(): void { // Inside a tick the tail is the only scheduler; setting the flag there is // what stops it parking on work it has just been told about. transportWakeRequested = true; - if (inTransportTick || state.transportRafId != null) return; + if (inTransportTick || transportTickPending()) return; if (transportParkTimerId != null) { window.clearTimeout(transportParkTimerId); transportParkTimerId = null; } transportWakeRequested = false; - state.transportRafId = window.requestAnimationFrame(transportTick); + requestTransportTick(); }; const followedOrLongestRunningAudio = ( @@ -5183,8 +5216,8 @@ export function initSandboxRuntimeModular(): void { installPositionEditsSeekReapply(window as Window & typeof globalThis); - // Start the rAF tick loop - state.transportRafId = window.requestAnimationFrame(transportTick); + // Start the tick loop + requestTransportTick(); postTimeline(); postState(true); @@ -5339,10 +5372,8 @@ export function initSandboxRuntimeModular(): void { const teardown = () => { if (state.tornDown) return; state.tornDown = true; - if (state.transportRafId != null) { - window.cancelAnimationFrame(state.transportRafId); - state.transportRafId = null; - } + cancelTransportTick(); + document.removeEventListener("visibilitychange", onTransportVisibilityChange); if (transportParkTimerId != null) { window.clearTimeout(transportParkTimerId); transportParkTimerId = null; diff --git a/packages/core/src/runtime/transportPark.test.ts b/packages/core/src/runtime/transportPark.test.ts index 71df2620bce..0906841ef8a 100644 --- a/packages/core/src/runtime/transportPark.test.ts +++ b/packages/core/src/runtime/transportPark.test.ts @@ -168,6 +168,7 @@ describe("parked transport loop", () => { afterEach(() => { window.__hfRuntimeTeardown?.(); + delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState; document.body.innerHTML = ""; window.__timelines = {} as Record; delete window.__player; @@ -983,4 +984,50 @@ describe("parked transport loop", () => { expect(window.__player!.isPlaying()).toBe(true); expect(media.isPaused()).toBe(true); }); + + const setVisibility = (state: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + document.dispatchEvent(new Event("visibilitychange")); + }; + + it("keeps the transport ticking on a timer while the document is hidden", () => { + // A hidden document runs no animation frames but its media keeps playing, so a + // transport driven by frames alone never ends a clip at the end of its window. + let nowMs = 1000; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); + mount(``); + const media = stubMediaPlayback(document.getElementById("a1") as HTMLAudioElement, 10); + initSandboxRuntimeModular(); + quiesce(); + + window.__player!.play(); + settle(); + expect(media.isPaused()).toBe(false); + + setVisibility("hidden"); + for (let i = 0; i < 100; i += 1) { + nowMs += 16; + vi.advanceTimersByTime(16); + } + + expect(window.__player!.getTime()).toBeGreaterThan(1); + expect(media.isPaused()).toBe(true); + }); + + it("moves a pending tick between animation frames and a timer as visibility changes", () => { + mount(); + initSandboxRuntimeModular(); + quiesce(); + window.__player!.play(); + expect(raf.pending()).toBe(1); + + setVisibility("hidden"); + expect(raf.pending()).toBe(0); + vi.advanceTimersByTime(16); + expect(raf.pending()).toBe(0); + expect(window.__player!.isPlaying()).toBe(true); + + setVisibility("visible"); + expect(raf.pending()).toBe(1); + }); });