fix(player): drive src URL timelines without runtime (#673)

* fix(player): drive src URL timelines without runtime

* fix(player): pause direct timeline after seek
This commit is contained in:
Miguel Ángel
2026-05-08 08:19:29 +02:00
committed by GitHub
parent b009288df7
commit f761ee2624
2 changed files with 290 additions and 28 deletions
@@ -790,12 +790,22 @@ describe("HyperframesPlayer seek() sync path", () => {
play?: () => void;
pause?: () => void;
};
type TimelineStub = {
duration: () => number;
time: () => number;
seek: (t: number) => void;
play: () => void;
pause: () => void;
};
type FakeContentWindow = {
__player?: SyncPlayerStub;
__timelines?: Record<string, TimelineStub>;
postMessage?: ReturnType<typeof vi.fn>;
};
type PlayerInternal = HTMLElement & {
seek: (t: number) => void;
play: () => void;
pause: () => void;
iframe: HTMLIFrameElement;
_currentTime: number;
};
@@ -874,6 +884,88 @@ describe("HyperframesPlayer seek() sync path", () => {
);
});
it("seeks same-origin __timelines when no runtime bridge exists", () => {
const timeline: TimelineStub = {
duration: vi.fn(() => 5),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
};
const post = vi.fn();
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
player.seek(2);
expect(timeline.seek).toHaveBeenCalledTimes(1);
expect(timeline.seek).toHaveBeenCalledWith(2);
expect(post).not.toHaveBeenCalled();
});
it("plays and pauses same-origin __timelines when no runtime bridge exists", () => {
const timeline: TimelineStub = {
duration: vi.fn(() => 5),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
};
const post = vi.fn();
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
player.play();
player.pause();
expect(timeline.play).toHaveBeenCalledTimes(1);
expect(timeline.pause).toHaveBeenCalledTimes(1);
expect(post).not.toHaveBeenCalled();
});
it("pauses same-origin __timelines after seek while playing", () => {
const pause = vi.fn();
const timeline: TimelineStub = {
duration: vi.fn(() => 5),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause,
};
const post = vi.fn();
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
player.play();
pause.mockClear();
player.seek(2);
expect(timeline.seek).toHaveBeenCalledWith(2);
expect(pause).toHaveBeenCalledTimes(1);
expect(post).not.toHaveBeenCalled();
});
it("does not bypass an installed runtime bridge for direct __timelines playback", () => {
const timeline: TimelineStub = {
duration: vi.fn(() => 5),
time: vi.fn(() => 0),
seek: vi.fn(),
play: vi.fn(),
pause: vi.fn(),
};
const post = vi.fn();
stubContentWindow({
__player: { play: vi.fn(), pause: vi.fn() },
__timelines: { main: timeline },
postMessage: post,
});
player.play();
player.pause();
expect(timeline.play).not.toHaveBeenCalled();
expect(timeline.pause).not.toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "play" }), "*");
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "pause" }), "*");
});
it("falls back to postMessage when __player exists but lacks seek()", () => {
// Defensive: a partial `__player` (e.g. older runtime, mocked stub) must
// not be assumed callable. `typeof seek !== "function"` guards this.
+198 -28
View File
@@ -50,6 +50,22 @@ interface ShaderLoaderElements {
frameRow: HTMLDivElement;
}
interface RuntimeDurationAdapter {
getDuration: () => number;
}
interface DirectTimelineAdapter {
duration: () => number;
time: () => number;
seek: (timeInSeconds: number) => unknown;
play: () => unknown;
pause: () => unknown;
}
type PlaybackDurationAdapter =
| { kind: "runtime"; getDuration: () => number }
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
const SHADER_LOADING_PHRASES = [
"Preparing scene transitions",
"Sampling outgoing scene motion",
@@ -110,6 +126,25 @@ function withShaderQueryParams(
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
return isObjectRecord(value) && typeof value.getDuration === "function";
}
function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
return (
isObjectRecord(value) &&
typeof value.duration === "function" &&
typeof value.time === "function" &&
typeof value.seek === "function" &&
typeof value.play === "function" &&
typeof value.pause === "function"
);
}
function injectShaderOptionsIntoSrcdoc(
html: string,
scale: string | null,
@@ -172,6 +207,8 @@ class HyperframesPlayer extends HTMLElement {
private _compositionHeight = 1080;
private _probeInterval: ReturnType<typeof setInterval> | null = null;
private _lastUpdateMs = 0;
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
private _directTimelineRaf: number | null = null;
/**
* Parent-frame audio/video proxies, preloaded mirror copies of the iframe's
@@ -308,6 +345,8 @@ class HyperframesPlayer extends HTMLElement {
window.removeEventListener("message", this._onMessage);
this.iframe.removeEventListener("load", this._onIframeLoad);
if (this._probeInterval) clearInterval(this._probeInterval);
this._stopDirectTimelineClock();
this._directTimelineAdapter = null;
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
this.shaderLoaderHideTimeout = null;
this._teardownMediaObserver();
@@ -418,18 +457,21 @@ class HyperframesPlayer extends HTMLElement {
if (this._duration > 0 && this._currentTime >= this._duration) {
this.seek(0);
}
// Always drive the iframe runtime — it's the single source of timeline
// truth regardless of who owns audible output. When we own audio, the
// proxies join; when the runtime owns, they stay silent.
this._sendControl("play");
// Drive the iframe runtime when present. Same-origin standalone GSAP
// compositions can expose only `window.__timelines`, so they use a direct
// timeline adapter instead of a postMessage bridge nobody is listening to.
const directTimelineStarted = this._tryDirectTimelinePlay();
if (!directTimelineStarted) this._sendControl("play");
if (this._audioOwner === "parent") this._playParentMedia();
this._paused = false;
this.controlsApi?.updatePlaying(true);
this.dispatchEvent(new Event("play"));
if (directTimelineStarted) this._startDirectTimelineClock();
}
pause() {
this._sendControl("pause");
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
this._stopDirectTimelineClock();
if (this._audioOwner === "parent") this._pauseParentMedia();
this._paused = true;
this.controlsApi?.updatePlaying(false);
@@ -461,10 +503,11 @@ class HyperframesPlayer extends HTMLElement {
* either way; the asymmetry only affects what the runtime actually paints.
*/
seek(timeInSeconds: number) {
if (!this._trySyncSeek(timeInSeconds)) {
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
this._sendControl("seek", { frame });
}
this._stopDirectTimelineClock();
this._currentTime = timeInSeconds;
// Mirror parent proxy currentTime only while parent owns audible output.
@@ -819,6 +862,150 @@ class HyperframesPlayer extends HTMLElement {
}
}
private _tryDirectTimelineSeek(timeInSeconds: number): boolean {
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
if (!timeline) return false;
try {
timeline.seek(timeInSeconds);
// GSAP seek() preserves play state; the player seek() contract lands paused.
timeline.pause();
this._directTimelineAdapter = timeline;
return true;
} catch {
return false;
}
}
private _tryDirectTimelinePlay(): boolean {
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
if (!timeline) return false;
try {
timeline.play();
this._directTimelineAdapter = timeline;
return true;
} catch {
return false;
}
}
private _tryDirectTimelinePause(): boolean {
const timeline = this._directTimelineAdapter || this._resolveDirectTimelineAdapter();
if (!timeline) return false;
try {
timeline.pause();
this._directTimelineAdapter = timeline;
return true;
} catch {
return false;
}
}
private _startDirectTimelineClock(): void {
this._stopDirectTimelineClock();
const tick = () => {
const timeline = this._directTimelineAdapter;
if (!timeline || this._paused) {
this._directTimelineRaf = null;
return;
}
let currentTime: number;
try {
currentTime = timeline.time();
} catch {
this._directTimelineRaf = null;
return;
}
if (this._duration > 0) currentTime = Math.min(currentTime, this._duration);
this._currentTime = currentTime;
const completedPlayback = this._duration > 0 && currentTime >= this._duration;
const now = performance.now();
if (now - this._lastUpdateMs > 100 || completedPlayback) {
this._lastUpdateMs = now;
this.controlsApi?.updateTime(this._currentTime, this._duration);
this.dispatchEvent(
new CustomEvent("timeupdate", { detail: { currentTime: this._currentTime } }),
);
}
if (completedPlayback) {
if (this.loop) {
this.seek(0);
this.play();
return;
}
timeline.pause();
if (this._audioOwner === "parent") this._pauseParentMedia();
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("ended"));
this._directTimelineRaf = null;
return;
}
this._directTimelineRaf = requestAnimationFrame(tick);
};
this._directTimelineRaf = requestAnimationFrame(tick);
}
private _stopDirectTimelineClock(): void {
if (this._directTimelineRaf === null) return;
cancelAnimationFrame(this._directTimelineRaf);
this._directTimelineRaf = null;
}
private _resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
try {
const win = this.iframe.contentWindow;
if (!win) return null;
return this._resolveDirectTimelineAdapterFromWindow(win);
} catch {
return null;
}
}
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
if (this._hasRuntimeBridge(win)) return null;
const timelines = Reflect.get(win, "__timelines");
if (!isObjectRecord(timelines)) return null;
const keys = Object.keys(timelines);
if (keys.length === 0) return null;
const rootId = this.iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
const timeline = timelines[key];
return isDirectTimelineAdapter(timeline) ? timeline : null;
}
private _hasRuntimeBridge(win: Window): boolean {
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
}
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
const runtimePlayer = Reflect.get(win, "__player");
if (isRuntimeDurationAdapter(runtimePlayer)) {
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
}
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
if (timeline) {
return {
kind: "direct-timeline",
timeline,
getDuration: () => timeline.duration(),
};
}
return null;
}
private _isControlsClick(event: Event) {
return event
.composedPath()
@@ -917,6 +1104,8 @@ class HyperframesPlayer extends HTMLElement {
private _onIframeLoad() {
let attempts = 0;
this._runtimeInjected = false;
this._directTimelineAdapter = null;
this._stopDirectTimelineClock();
this._resetShaderLoader();
// A fresh iframe means a fresh runtime — `mediaOutputMuted` and the
// autoplay-blocked latch are both reset inside it. The web component's
@@ -976,31 +1165,12 @@ class HyperframesPlayer extends HTMLElement {
return;
}
const getAdapter = () => {
if (win.__player && typeof win.__player.getDuration === "function") return win.__player;
if (win.__timelines) {
const keys = Object.keys(win.__timelines);
if (keys.length > 0) {
// Resolve the root composition id from the DOM — the outermost
// `[data-composition-id]` element is the master. Bundled previews
// register the root composition alongside sub-compositions, and
// without this lookup Object.keys() order would make a
// sub-composition's duration hijack the overall video length.
const rootId = this.iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
const tl = win.__timelines[key];
return { getDuration: () => tl.duration() };
}
}
return null;
};
const adapter = getAdapter();
const adapter = this._resolvePlaybackDurationAdapter(win);
if (adapter && adapter.getDuration() > 0) {
clearInterval(this._probeInterval!);
this._duration = adapter.getDuration();
this._directTimelineAdapter =
adapter.kind === "direct-timeline" ? adapter.timeline : null;
this._ready = true;
this.controlsApi?.updateTime(0, this._duration);
this.dispatchEvent(new CustomEvent("ready", { detail: { duration: this._duration } }));