Skip to content
Open
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
157 changes: 157 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,3 +640,160 @@ describe("HyperframesPlayer parent-proxy time-mirror coalescing", () => {
expect(forcedCall).toBeDefined();
});
});

// ── Synchronous seek() with same-origin detection ──
//
// Studio has long reached past the postMessage bridge and called the runtime's
// `__player.seek` directly (`useTimelinePlayer.ts:233`) — that's the only way
// to land a scrubbed frame in the same task as the input event so the user
// sees no perceived lag. P3-1 promotes that pattern to a public API: the
// player element's own `seek()` now tries the same shortcut first, and only
// falls back to the async postMessage bridge when the iframe is genuinely
// cross-origin (or the runtime hasn't installed `__player` yet). The tests
// here stub `iframe.contentWindow` so we can exercise the branch matrix
// without booting an actual runtime.

describe("HyperframesPlayer seek() sync path", () => {
type SyncPlayerStub = {
seek?: (t: number) => void;
play?: () => void;
pause?: () => void;
};
type FakeContentWindow = {
__player?: SyncPlayerStub;
postMessage?: ReturnType<typeof vi.fn>;
};
type PlayerInternal = HTMLElement & {
seek: (t: number) => void;
iframe: HTMLIFrameElement;
_currentTime: number;
};

let player: PlayerInternal;

beforeEach(async () => {
await import("./hyperframes-player.js");
player = document.createElement("hyperframes-player") as PlayerInternal;
document.body.appendChild(player);
});

afterEach(() => {
player.remove();
vi.restoreAllMocks();
});

// Replace the iframe's `contentWindow` getter so the test controls what the
// sync path sees. Passing `"throw"` simulates the cross-origin SecurityError
// a real browser raises when reading `contentWindow.<anything>`.
function stubContentWindow(stub: FakeContentWindow | "throw") {
Object.defineProperty(player.iframe, "contentWindow", {
configurable: true,
get() {
if (stub === "throw") throw new Error("SecurityError");
return stub;
},
});
}

it("calls __player.seek directly on the same-origin path", () => {
// The whole point of P3-1: when the runtime is reachable, scrubs land in
// the same task as the input. `postMessage` must NOT also fire — that
// would cause a duplicate, async re-seek a tick later.
const sync = vi.fn();
const post = vi.fn();
stubContentWindow({ __player: { seek: sync }, postMessage: post });

player.seek(12.5);

expect(sync).toHaveBeenCalledTimes(1);
expect(sync).toHaveBeenCalledWith(12.5);
expect(post).not.toHaveBeenCalled();
});

it("passes the raw time-in-seconds through, not a rounded frame number", () => {
// The postMessage bridge has to round to a frame at the wire boundary,
// but the in-process call accepts seconds directly — preserving the
// caller's precision for fractional scrubs.
const sync = vi.fn();
stubContentWindow({ __player: { seek: sync } });

player.seek(7.3333);

expect(sync).toHaveBeenCalledWith(7.3333);
});

it("falls back to postMessage when __player has not been installed yet", () => {
// Before the runtime bootstraps, `contentWindow` exists but `__player` is
// undefined. The fallback queues the seek via postMessage, which the
// runtime drains once `installRuntimeControlBridge` runs.
const post = vi.fn();
stubContentWindow({ postMessage: post });

player.seek(12.5);

expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "seek",
frame: Math.round(12.5 * 30),
}),
"*",
);
});

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.
const post = vi.fn();
stubContentWindow({
__player: { play: vi.fn(), pause: vi.fn() },
postMessage: post,
});

player.seek(7);

expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek", frame: 210 }), "*");
});

it("does not throw when contentWindow access raises (cross-origin embed)", () => {
// Reading `iframe.contentWindow` on a true cross-origin iframe throws a
// DOMException. Both `_trySyncSeek` AND the postMessage fallback hit the
// same getter, so both swallow the error — the public seek() must remain
// a clean no-op surface for the caller.
stubContentWindow("throw");

expect(() => player.seek(12.5)).not.toThrow();
});

it("falls back to postMessage when __player.seek throws at runtime", () => {
// If the runtime's seek implementation panics, we catch in `_trySyncSeek`
// and degrade to the bridge. The postMessage path runs in a separate
// task — it may succeed where the sync call failed, and at worst the
// failure mode is identical.
const sync = vi.fn(() => {
throw new Error("runtime panic");
});
const post = vi.fn();
stubContentWindow({ __player: { seek: sync }, postMessage: post });

expect(() => player.seek(12.5)).not.toThrow();
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek" }), "*");
});

it("updates _currentTime regardless of which path is taken", () => {
// `_currentTime` is the parent-side cache that drives controls and parent
// proxy mirroring. It must update unconditionally — otherwise scrubs on a
// cross-origin embed leave the controls UI showing stale time.
const sync = vi.fn();
stubContentWindow({ __player: { seek: sync } });
player.seek(8.25);
expect(player._currentTime).toBe(8.25);

// Reset and verify the fallback path produces the same caching behavior.
stubContentWindow({ postMessage: vi.fn() });
player.seek(11);
expect(player._currentTime).toBe(11);
});
});
61 changes: 59 additions & 2 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,35 @@ class HyperframesPlayer extends HTMLElement {
this.dispatchEvent(new Event("pause"));
}

/**
* Move playback to `timeInSeconds`.
*
* Two transports, with different precision semantics — read this before
* writing assertions against `seek` from outside the player:
*
* - **Same-origin (sync) path** — when the runtime's `window.__player.seek`
* is reachable, we call it directly. `timeInSeconds` is forwarded
* *verbatim* (no rounding), so a same-origin scrub of `seek(7.3333)`
* lands the runtime at `7.3333 s` — sub-frame precision relative to
* `DEFAULT_FPS` (30). Studio scrub UIs that need fractional-frame
* alignment (e.g. waveform scrubbing on long-duration audio) get the
* exact requested time.
* - **Cross-origin (postMessage) path** — when same-origin access throws
* or `__player.seek` is missing, we fall back to the postMessage bridge.
* The wire protocol carries integer frames (`frame: Math.round(t × FPS)`),
* so cross-origin embeds are *frame-quantized* and `seek(7.3333)` lands
* at `Math.round(7.3333 × 30) / 30 ≈ 7.3333…` (same value here, but for
* most fractional inputs you'll see a snap to the nearest 1/30 s).
*
* `this._currentTime` always reflects the *requested* `timeInSeconds`
* regardless of transport, so the controls UI shows the un-quantized value
* either way; the asymmetry only affects what the runtime actually paints.
*/
seek(timeInSeconds: number) {
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
this._sendControl("seek", { frame });
if (!this._trySyncSeek(timeInSeconds)) {
const frame = Math.round(timeInSeconds * DEFAULT_FPS);
this._sendControl("seek", { frame });
}
this._currentTime = timeInSeconds;

// Mirror parent proxy currentTime only while parent owns audible output.
Expand Down Expand Up @@ -320,6 +346,37 @@ class HyperframesPlayer extends HTMLElement {
}
}

/**
* Reach into the runtime's `window.__player.seek` directly, skipping the
* postMessage hop. Same-origin only — cross-origin embeds throw a
* `SecurityError` on `contentWindow` property access, which we catch and
* report as a no-op so the caller can transparently fall back to the
* postMessage bridge. Returns `true` only when the runtime accepted the
* call (`__player.seek` exists, is callable, and didn't throw).
*
* Studio has used this access path privately via `iframe.contentWindow.__player`
* (see `useTimelinePlayer.ts`); this helper just formalizes the same
* detection inside the player so external scrub UIs get the same
* single-task latency. The runtime-side `seek` is the same wrapped
* function the postMessage handler calls (`installRuntimeControlBridge`
* routes through `player.seek`), so `markExplicitSeek()` and downstream
* runtime state stay identical between the two paths.
*/
private _trySyncSeek(timeInSeconds: number): boolean {
try {
const win = this.iframe.contentWindow as
| (Window & { __player?: { seek?: (t: number) => void } })
| null;
const player = win?.__player;
const seek = player?.seek;
if (typeof seek !== "function") return false;
seek.call(player, timeInSeconds);
return true;
} catch {
return false;
}
}

private _isControlsClick(event: Event) {
return event
.composedPath()
Expand Down
Loading