Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/runtime/transportPark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ describe("parked transport loop", () => {

afterEach(() => {
window.__hfRuntimeTeardown?.();
delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState;
document.body.innerHTML = "";
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
delete window.__player;
Expand Down Expand Up @@ -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(`<audio id="a1" data-start="0" data-duration="1"></audio>`);
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);
});
});
Loading