mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Summary - `seek()` only called `seekAll()` under parent audio ownership, leaving the `<audio>` proxy playing while the timeline froze at the new seek target. - The periodic `mirrorTime` drift-correction (`parent-media.ts`) would then yank `currentTime` back to the timeline position every ~80ms of accumulated drift, producing an audible stutter loop while the video frame stayed frozen. - Fix: make `seek()` symmetric with `pause()` — pause the parent proxy before seeking it. ## Repro 1. Use the player in an environment where the runtime posts `media-autoplay-blocked` (mobile / autoplay-restricted contexts), promoting audio ownership to `"parent"`. 2. Start playback with audio. 3. Click anywhere on the scrubber while playing. 4. Before: audio stutters in a short loop while the video frame is frozen. 5. After: audio cleanly pauses at the new seek position. ## Test plan - [x] Added regression test \`seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)\` in \`hyperframes-player.test.ts\`. - [x] \`pnpm --filter @hyperframes/player test\` — 110/110 pass. - [ ] Manual repro on a device where ownership flips to \`parent\`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1499 lines
50 KiB
TypeScript
1499 lines
50 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js";
|
|
|
|
// ── Controls unit tests ──
|
|
|
|
describe("SPEED_PRESETS", () => {
|
|
it("contains logarithmic speed steps", () => {
|
|
expect(SPEED_PRESETS).toEqual([0.25, 0.5, 1, 1.5, 2, 4]);
|
|
});
|
|
|
|
it("includes 1x as default speed", () => {
|
|
expect(SPEED_PRESETS).toContain(1);
|
|
});
|
|
});
|
|
|
|
describe("formatSpeed", () => {
|
|
it("formats integer speeds", () => {
|
|
expect(formatSpeed(1)).toBe("1x");
|
|
expect(formatSpeed(2)).toBe("2x");
|
|
expect(formatSpeed(4)).toBe("4x");
|
|
});
|
|
|
|
it("formats fractional speeds", () => {
|
|
expect(formatSpeed(0.25)).toBe("0.25x");
|
|
expect(formatSpeed(0.5)).toBe("0.5x");
|
|
expect(formatSpeed(1.5)).toBe("1.5x");
|
|
});
|
|
});
|
|
|
|
describe("formatTime", () => {
|
|
it("formats 0 seconds", () => {
|
|
expect(formatTime(0)).toBe("0:00");
|
|
});
|
|
|
|
it("formats seconds under a minute", () => {
|
|
expect(formatTime(45)).toBe("0:45");
|
|
});
|
|
|
|
it("formats exact minutes", () => {
|
|
expect(formatTime(120)).toBe("2:00");
|
|
});
|
|
|
|
it("formats minutes and seconds", () => {
|
|
expect(formatTime(95)).toBe("1:35");
|
|
});
|
|
|
|
it("pads seconds with leading zero", () => {
|
|
expect(formatTime(61)).toBe("1:01");
|
|
});
|
|
|
|
it("floors fractional seconds", () => {
|
|
expect(formatTime(3.7)).toBe("0:03");
|
|
});
|
|
|
|
it("handles negative input", () => {
|
|
expect(formatTime(-5)).toBe("0:00");
|
|
});
|
|
});
|
|
|
|
// ── Parent-frame audio proxies (ownership-based) ──
|
|
//
|
|
// Parent-frame audio/video copies are preloaded mirror proxies of the iframe's
|
|
// timed media. They exist as a fallback for environments that block iframe
|
|
// `.play()`. Under the default `runtime` audio ownership, the iframe drives
|
|
// audible playback and the proxies stay paused. Ownership flips to `parent`
|
|
// only when the runtime posts `media-autoplay-blocked` — then the proxies
|
|
// become the audible source and the iframe is silenced via bridge.
|
|
|
|
describe("HyperframesPlayer parent-frame media", () => {
|
|
type PlayerElement = HTMLElement & {
|
|
play: () => void;
|
|
pause: () => void;
|
|
seek: (t: number) => void;
|
|
_audioOwner?: "runtime" | "parent";
|
|
_promoteToParentProxy?: () => void;
|
|
};
|
|
|
|
let player: PlayerElement;
|
|
let mockAudio: {
|
|
src: string;
|
|
preload: string;
|
|
muted: boolean;
|
|
playbackRate: number;
|
|
currentTime: number;
|
|
paused: boolean;
|
|
play: ReturnType<typeof vi.fn>;
|
|
pause: ReturnType<typeof vi.fn>;
|
|
load: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
|
|
mockAudio = {
|
|
src: "",
|
|
preload: "",
|
|
muted: false,
|
|
playbackRate: 1,
|
|
currentTime: 0,
|
|
paused: true,
|
|
play: vi.fn().mockResolvedValue(undefined),
|
|
pause: vi.fn(),
|
|
load: vi.fn(),
|
|
};
|
|
|
|
vi.spyOn(globalThis, "Audio").mockImplementation(
|
|
() => mockAudio as unknown as HTMLAudioElement,
|
|
);
|
|
|
|
player = document.createElement("hyperframes-player") as PlayerElement;
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("includes audio-src in observedAttributes", () => {
|
|
const Ctor = player.constructor as typeof HTMLElement & {
|
|
observedAttributes: string[];
|
|
};
|
|
expect(Ctor.observedAttributes).toContain("audio-src");
|
|
});
|
|
|
|
it("creates Audio and starts preloading when audio-src is set", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
expect(globalThis.Audio).toHaveBeenCalled();
|
|
expect(mockAudio.preload).toBe("auto");
|
|
expect(mockAudio.src).toBe("https://cdn.example.com/narration.mp3");
|
|
expect(mockAudio.load).toHaveBeenCalled();
|
|
});
|
|
|
|
it("syncs muted attribute to parent media", () => {
|
|
player.setAttribute("muted", "");
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
expect(mockAudio.muted).toBe(true);
|
|
});
|
|
|
|
it("syncs playback-rate to parent media", () => {
|
|
player.setAttribute("playback-rate", "1.5");
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
expect(mockAudio.playbackRate).toBe(1.5);
|
|
});
|
|
|
|
it("play() does NOT start parent-proxy under runtime ownership", () => {
|
|
// Default ownership is `runtime` — the iframe drives audible playback.
|
|
// If we also started parent proxies here, both would play and the user
|
|
// would hear doubled, slightly-offset audio (the original bug).
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.play();
|
|
expect(mockAudio.play).not.toHaveBeenCalled();
|
|
expect(player._audioOwner).toBe("runtime");
|
|
});
|
|
|
|
it("pause() does NOT touch parent-proxy under runtime ownership", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.pause();
|
|
expect(mockAudio.pause).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("seek() does NOT update parent currentTime under runtime ownership", () => {
|
|
// Under runtime ownership the iframe is authoritative for time; touching
|
|
// the proxy's currentTime would just trigger a re-buffer for no gain.
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.seek(12.5);
|
|
expect(mockAudio.currentTime).toBe(0);
|
|
});
|
|
|
|
it("after promotion to parent ownership: play/pause/seek drive parent proxy", () => {
|
|
// Simulates the runtime having posted `media-autoplay-blocked`. Post
|
|
// promotion: the web component owns audible output and fully drives
|
|
// the parent proxy.
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player._promoteToParentProxy?.();
|
|
expect(player._audioOwner).toBe("parent");
|
|
|
|
player.play();
|
|
expect(mockAudio.play).toHaveBeenCalled();
|
|
|
|
player.seek(12.5);
|
|
expect(mockAudio.currentTime).toBe(12.5);
|
|
|
|
player.pause();
|
|
expect(mockAudio.pause).toHaveBeenCalled();
|
|
});
|
|
|
|
it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => {
|
|
// Regression: previously `seek()` only called `seekAll()`, leaving the
|
|
// proxy playing. With the timeline frozen at the new seek target, the
|
|
// parent's `mirrorTime` drift-correction would yank `currentTime` back
|
|
// every ~80ms of accumulated drift, producing an audible audio stutter
|
|
// loop while the video frame stayed frozen. `seek()` must be symmetric
|
|
// with `pause()` for the parent-owned audio path.
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player._promoteToParentProxy?.();
|
|
player.play();
|
|
expect(mockAudio.play).toHaveBeenCalled();
|
|
mockAudio.pause.mockClear();
|
|
|
|
player.seek(12.5);
|
|
expect(mockAudio.pause).toHaveBeenCalled();
|
|
expect(mockAudio.currentTime).toBe(12.5);
|
|
});
|
|
|
|
it("promotion is idempotent", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player._promoteToParentProxy?.();
|
|
player._promoteToParentProxy?.();
|
|
player._promoteToParentProxy?.();
|
|
// Only one play() attempt is triggered by promotion itself (gated on
|
|
// `!this._paused`, which is true by default so it doesn't trigger at all).
|
|
// The test's meaning is: ownership stays `parent`, no thrash, no errors.
|
|
expect(player._audioOwner).toBe("parent");
|
|
});
|
|
|
|
it("dispatches audioownershipchange on promotion", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
const events: Array<{ owner: string; reason: string }> = [];
|
|
player.addEventListener("audioownershipchange", (e: Event) => {
|
|
const detail = (e as CustomEvent<{ owner: string; reason: string }>).detail;
|
|
events.push(detail);
|
|
});
|
|
|
|
player._promoteToParentProxy?.();
|
|
expect(events).toEqual([{ owner: "parent", reason: "autoplay-blocked" }]);
|
|
|
|
// Second promote is idempotent — no duplicate event.
|
|
player._promoteToParentProxy?.();
|
|
expect(events).toHaveLength(1);
|
|
});
|
|
|
|
it("promotion mid-playback plays parent proxy immediately", () => {
|
|
// Previously-missing coverage: if the user is already playing when
|
|
// the runtime reports autoplay-blocked, the proxy must start audible
|
|
// right away — not wait for the user to hit pause/play again.
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.play(); // `_paused = false`, owner still `runtime` → no parent play yet
|
|
expect(mockAudio.play).not.toHaveBeenCalled();
|
|
|
|
player._promoteToParentProxy?.();
|
|
expect(mockAudio.play).toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces playbackerror when parent proxy play() rejects", async () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
|
mockAudio.play = vi.fn().mockRejectedValueOnce(rejection);
|
|
|
|
const errors: unknown[] = [];
|
|
player.addEventListener("playbackerror", (e: Event) => {
|
|
errors.push((e as CustomEvent).detail);
|
|
});
|
|
|
|
player._promoteToParentProxy?.();
|
|
player.play();
|
|
// Promise rejection delivered on a microtask — flush.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect((errors[0] as { source: string }).source).toBe("parent-proxy");
|
|
});
|
|
|
|
it("playbackerror dedup: fires at most once per parent-ownership session", async () => {
|
|
// Under parent ownership with parent-also-blocked, every iframe
|
|
// paused→playing transition in the state loop re-invokes `_playParentMedia`.
|
|
// Without a latch, each rejection would re-fire `playbackerror`, spamming
|
|
// subscribers. Mirrors the runtime's `mediaAutoplayBlockedPosted` latch.
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
|
|
mockAudio.play = vi.fn().mockRejectedValue(rejection);
|
|
|
|
const errors: unknown[] = [];
|
|
player.addEventListener("playbackerror", (e: Event) => {
|
|
errors.push((e as CustomEvent).detail);
|
|
});
|
|
|
|
player._promoteToParentProxy?.();
|
|
player.play();
|
|
player.pause();
|
|
player.play();
|
|
player.pause();
|
|
player.play();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(errors).toHaveLength(1);
|
|
});
|
|
|
|
it("cleans up parent media on disconnect", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.remove();
|
|
expect(mockAudio.pause).toHaveBeenCalled();
|
|
expect(mockAudio.src).toBe("");
|
|
});
|
|
|
|
it("updates parent media when playback-rate changes after setup", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("playback-rate", "2");
|
|
expect(mockAudio.playbackRate).toBe(2);
|
|
});
|
|
|
|
it("updates parent media when muted toggles after setup", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("muted", "");
|
|
expect(mockAudio.muted).toBe(true);
|
|
|
|
player.removeAttribute("muted");
|
|
expect(mockAudio.muted).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Shader transition preview controls ──
|
|
//
|
|
// Shader transition capture scale and loading UI ownership are player-level
|
|
// preview concerns. The player forwards those options into the iframe before
|
|
// the composition runs, then renders transition-prep progress from runtime
|
|
// messages when `shader-loading="player"` is enabled.
|
|
|
|
describe("HyperframesPlayer shader transition options", () => {
|
|
type PlayerWithIframe = HTMLElement & {
|
|
iframeElement: HTMLIFrameElement;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("observes shader capture scale and loading attributes", () => {
|
|
const player = document.createElement("hyperframes-player");
|
|
const Ctor = player.constructor as typeof HTMLElement & {
|
|
observedAttributes: string[];
|
|
};
|
|
|
|
expect(Ctor.observedAttributes).toContain("shader-capture-scale");
|
|
expect(Ctor.observedAttributes).toContain("shader-loading");
|
|
});
|
|
|
|
it("passes shader options through src query parameters", () => {
|
|
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
|
player.setAttribute("shader-capture-scale", "0.5");
|
|
player.setAttribute("shader-loading", "player");
|
|
player.setAttribute("src", "/api/projects/demo/preview?x=1#stage");
|
|
|
|
const url = new URL(player.iframeElement.src);
|
|
expect(url.pathname).toBe("/api/projects/demo/preview");
|
|
expect(url.searchParams.get("x")).toBe("1");
|
|
expect(url.searchParams.get("__hf_shader_capture_scale")).toBe("0.5");
|
|
expect(url.searchParams.get("__hf_shader_loading")).toBe("player");
|
|
expect(url.hash).toBe("#stage");
|
|
});
|
|
|
|
it("injects shader options into srcdoc before composition scripts run", () => {
|
|
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
|
player.setAttribute("shader-capture-scale", "0.5");
|
|
player.setAttribute("shader-loading", "player");
|
|
player.setAttribute(
|
|
"srcdoc",
|
|
'<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
|
|
);
|
|
|
|
const srcdoc = player.iframeElement.srcdoc;
|
|
expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";');
|
|
expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";');
|
|
expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan(
|
|
srcdoc.indexOf("composition.js"),
|
|
);
|
|
});
|
|
|
|
it("shows and hides the player-owned shader loader from transition state messages", () => {
|
|
vi.useFakeTimers();
|
|
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
|
player.setAttribute("shader-loading", "player");
|
|
document.body.appendChild(player);
|
|
|
|
const iframeWindow = player.iframeElement.contentWindow;
|
|
expect(iframeWindow).toBeTruthy();
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
source: iframeWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "shader-transition-state",
|
|
compositionId: "main",
|
|
state: {
|
|
loading: true,
|
|
progress: 3,
|
|
total: 10,
|
|
currentTransition: 1,
|
|
transitionTotal: 2,
|
|
transitionFrame: 3,
|
|
transitionFrames: 5,
|
|
phase: "capturing",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
|
|
expect(loader?.classList.contains("hfp-visible")).toBe(true);
|
|
expect(loader?.textContent).toContain("1/2");
|
|
expect(loader?.textContent).toContain("3/5");
|
|
|
|
const playEvents: Event[] = [];
|
|
player.addEventListener("play", (event) => playEvents.push(event));
|
|
loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
|
|
expect(playEvents).toHaveLength(0);
|
|
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
source: iframeWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "shader-transition-state",
|
|
compositionId: "main",
|
|
state: { loading: false, ready: true },
|
|
},
|
|
}),
|
|
);
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
source: iframeWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "shader-transition-state",
|
|
compositionId: "main",
|
|
state: { loading: false, ready: true },
|
|
},
|
|
}),
|
|
);
|
|
expect(loader?.classList.contains("hfp-visible")).toBe(false);
|
|
expect(loader?.classList.contains("hfp-hiding")).toBe(true);
|
|
vi.advanceTimersByTime(420);
|
|
expect(loader?.classList.contains("hfp-hiding")).toBe(false);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
// ── Shared stylesheet (adoptedStyleSheets) ──
|
|
//
|
|
// Every player constructed in the same document should adopt the *same*
|
|
// CSSStyleSheet instance instead of getting its own <style> element. This is
|
|
// the studio thumbnail-grid win — N players, one parsed sheet.
|
|
|
|
describe("HyperframesPlayer adoptedStyleSheets", () => {
|
|
type AdoptingShadowRoot = ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
|
|
type PlayerWithShadow = HTMLElement & { shadowRoot: AdoptingShadowRoot | null };
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("shares a single CSSStyleSheet across multiple player instances", () => {
|
|
const a = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
const b = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
document.body.appendChild(a);
|
|
document.body.appendChild(b);
|
|
|
|
const sheetsA = a.shadowRoot?.adoptedStyleSheets ?? [];
|
|
const sheetsB = b.shadowRoot?.adoptedStyleSheets ?? [];
|
|
|
|
expect(sheetsA.length).toBeGreaterThan(0);
|
|
expect(sheetsB.length).toBeGreaterThan(0);
|
|
expect(sheetsA.at(-1)).toBe(sheetsB.at(-1));
|
|
});
|
|
|
|
it("does not inject a per-instance <style> when adoption succeeds", () => {
|
|
const player = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.shadowRoot?.querySelector("style")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Media MutationObserver scoping ──
|
|
//
|
|
// The observer that catches late-attached `<audio data-start>` from
|
|
// sub-composition activation used to watch `iframe.contentDocument.body`
|
|
// wholesale. That fired on every body-level mutation — analytics scripts,
|
|
// runtime telemetry markers, dev-only overlays — even though only
|
|
// composition-tree changes can introduce new timed media. The fix is to
|
|
// scope per top-level composition host (see `selectMediaObserverTargets`);
|
|
// these tests verify the player honors that scoping.
|
|
|
|
describe("HyperframesPlayer media MutationObserver scoping", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
_observeDynamicMedia?: (doc: Document) => void;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("attaches the observer to each top-level composition host (not the body)", () => {
|
|
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");
|
|
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
// The constructor doesn't install an observer — only `_observeDynamicMedia`
|
|
// does — so the spy starts clean for the call we care about.
|
|
observeSpy.mockClear();
|
|
|
|
// Simulates the iframe document the runtime hands the player after mount.
|
|
// Bypassing the iframe lifecycle keeps the test deterministic; the
|
|
// selection logic itself is exercised in `mediaObserverScope.test.ts`.
|
|
const fakeDoc = document.implementation.createHTMLDocument("test");
|
|
fakeDoc.body.innerHTML = `
|
|
<div data-composition-id="root-a"></div>
|
|
<div data-composition-id="root-b"></div>
|
|
<script>// runtime telemetry — body-level, must NOT be observed</script>
|
|
`;
|
|
|
|
player._observeDynamicMedia?.(fakeDoc);
|
|
|
|
expect(observeSpy).toHaveBeenCalledTimes(2);
|
|
const observedTargets = observeSpy.mock.calls.map((call) => call[0]);
|
|
expect(observedTargets.map((t) => (t as Element).getAttribute("data-composition-id"))).toEqual([
|
|
"root-a",
|
|
"root-b",
|
|
]);
|
|
expect(observedTargets).not.toContain(fakeDoc.body);
|
|
// Subtree is still required — sub-composition media can be deeply nested
|
|
// inside the host (e.g. wrapper div around the `<audio>`).
|
|
// Attribute observation on "preload" is required so the player creates
|
|
// parent proxies just-in-time when the preloader promotes a clip.
|
|
for (const call of observeSpy.mock.calls) {
|
|
expect(call[1]).toEqual({
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["preload"],
|
|
});
|
|
}
|
|
});
|
|
|
|
it("falls back to observing the document body when no composition hosts exist", () => {
|
|
// Preserves the legacy behavior for documents that haven't bootstrapped
|
|
// a composition tree yet (e.g. a blank iframe between src changes).
|
|
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");
|
|
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
observeSpy.mockClear();
|
|
|
|
const fakeDoc = document.implementation.createHTMLDocument("test");
|
|
fakeDoc.body.innerHTML = `<div class="not-a-composition"></div>`;
|
|
|
|
player._observeDynamicMedia?.(fakeDoc);
|
|
|
|
expect(observeSpy).toHaveBeenCalledTimes(1);
|
|
expect(observeSpy.mock.calls[0]?.[0]).toBe(fakeDoc.body);
|
|
});
|
|
});
|
|
|
|
// ── Parent-proxy time-mirror coalescing ──
|
|
//
|
|
// `_mirrorParentMediaTime` is the steady-state correction loop that nudges
|
|
// every parent-frame audio/video proxy back onto the iframe's timeline. The
|
|
// post-`P1-4` contract: a single over-threshold sample (one slow bridge tick,
|
|
// one tab-throttled rAF, one GC pause) is absorbed by a per-proxy counter and
|
|
// does NOT cost a `currentTime` write. Only a *trending* drift — two
|
|
// consecutive samples above the 50 ms threshold — triggers a seek. Forced
|
|
// callers (audio-ownership promotion, brand-new proxy initialization) bypass
|
|
// the gate so the listener never hears a misaligned sample on cut-over.
|
|
|
|
describe("HyperframesPlayer parent-proxy time-mirror coalescing", () => {
|
|
type DriftEntry = {
|
|
el: { currentTime: number; src: string; pause: () => void };
|
|
start: number;
|
|
duration: number;
|
|
driftSamples: number;
|
|
};
|
|
type PlayerInternal = HTMLElement & {
|
|
_parentMedia: DriftEntry[];
|
|
_mirrorParentMediaTime: (timelineSeconds: number, options?: { force?: boolean }) => void;
|
|
_promoteToParentProxy?: () => void;
|
|
};
|
|
|
|
let player: PlayerInternal;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
// No audio-src was set, so `_parentMedia` is empty. Tests push synthetic
|
|
// POJO entries — `_mirrorParentMediaTime` only reads/writes
|
|
// `el.currentTime`, so a plain object stands in fine for HTMLMediaElement.
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function makeEntry(
|
|
opts: {
|
|
currentTime?: number;
|
|
start?: number;
|
|
duration?: number;
|
|
driftSamples?: number;
|
|
} = {},
|
|
): DriftEntry {
|
|
// Include `pause`/`src` so `disconnectedCallback`'s teardown loop
|
|
// (`m.el.pause(); m.el.src = ""`) doesn't blow up when the player is
|
|
// removed at the end of the test — `_mirrorParentMediaTime` itself only
|
|
// touches `currentTime`.
|
|
const entry: DriftEntry = {
|
|
el: {
|
|
currentTime: opts.currentTime ?? 0,
|
|
src: "",
|
|
pause: vi.fn(),
|
|
},
|
|
start: opts.start ?? 0,
|
|
duration: opts.duration ?? 100,
|
|
driftSamples: opts.driftSamples ?? 0,
|
|
};
|
|
player._parentMedia.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
it("initializes new parent-media entries with driftSamples=0", () => {
|
|
// Mock Audio just for this test so the audio-src bootstrap path produces
|
|
// a real entry rather than throwing on construction.
|
|
const mockAudio = {
|
|
src: "",
|
|
preload: "",
|
|
muted: false,
|
|
playbackRate: 1,
|
|
currentTime: 0,
|
|
paused: true,
|
|
play: vi.fn().mockResolvedValue(undefined),
|
|
pause: vi.fn(),
|
|
load: vi.fn(),
|
|
};
|
|
vi.spyOn(globalThis, "Audio").mockImplementation(
|
|
() => mockAudio as unknown as HTMLAudioElement,
|
|
);
|
|
|
|
const fresh = document.createElement("hyperframes-player") as PlayerInternal;
|
|
fresh.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(fresh);
|
|
|
|
expect(fresh._parentMedia).toHaveLength(1);
|
|
expect(fresh._parentMedia[0]?.driftSamples).toBe(0);
|
|
fresh.remove();
|
|
});
|
|
|
|
it("does nothing when drift is within the 50 ms threshold", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.04);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("absorbs a single over-threshold spike without writing currentTime", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(1);
|
|
});
|
|
|
|
it("issues a seek on the second consecutive over-threshold sample", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(1);
|
|
// Second sample with the same drift: the gate trips, the write fires,
|
|
// and the counter resets so the proxy doesn't re-seek every later tick.
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("resets the counter when a sample comes back within threshold", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.driftSamples).toBe(1);
|
|
// Recovery — counter must clear so a later isolated spike doesn't
|
|
// accidentally satisfy the 2-sample gate by piggy-backing on stale state.
|
|
player._mirrorParentMediaTime(5.02);
|
|
expect(m.driftSamples).toBe(0);
|
|
expect(m.el.currentTime).toBe(5);
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.driftSamples).toBe(1);
|
|
expect(m.el.currentTime).toBe(5);
|
|
});
|
|
|
|
it("force: true writes immediately on the first over-threshold sample", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5, { force: true });
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("force: true clears any pre-existing drift counter", () => {
|
|
const m = makeEntry({ currentTime: 5, driftSamples: 1 });
|
|
player._mirrorParentMediaTime(5.5, { force: true });
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("does not seek out-of-range entries and resets their counters", () => {
|
|
// Active window [10, 15). currentTime=99 is a sentinel — if the function
|
|
// ever writes inside an out-of-range branch the test catches it because
|
|
// relTime would be 5 (or 15), not 99.
|
|
const m = makeEntry({
|
|
currentTime: 99,
|
|
start: 10,
|
|
duration: 5,
|
|
driftSamples: 5,
|
|
});
|
|
player._mirrorParentMediaTime(5);
|
|
expect(m.el.currentTime).toBe(99);
|
|
expect(m.driftSamples).toBe(0);
|
|
// Boundary: relTime === duration → still out of range (the loop uses `>=`).
|
|
m.driftSamples = 7;
|
|
player._mirrorParentMediaTime(15);
|
|
expect(m.el.currentTime).toBe(99);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("tracks drift independently across multiple proxies", () => {
|
|
// a is drifted; b is aligned. A single tick must increment a's counter
|
|
// and reset b's — proving the per-entry state is genuinely per-entry.
|
|
const a = makeEntry({ currentTime: 5 });
|
|
const b = makeEntry({ currentTime: 7.01, driftSamples: 1 });
|
|
player._mirrorParentMediaTime(7);
|
|
expect(a.el.currentTime).toBe(5);
|
|
expect(a.driftSamples).toBe(1);
|
|
expect(b.el.currentTime).toBe(7.01);
|
|
expect(b.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("force: true bypasses the gate for every proxy in a single sweep", () => {
|
|
const a = makeEntry({ currentTime: 5 });
|
|
const b = makeEntry({ currentTime: 8 });
|
|
player._mirrorParentMediaTime(7, { force: true });
|
|
expect(a.el.currentTime).toBe(7);
|
|
expect(b.el.currentTime).toBe(7);
|
|
expect(a.driftSamples).toBe(0);
|
|
expect(b.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("_promoteToParentProxy invokes _mirrorParentMediaTime with force: true", () => {
|
|
// Integration check of the promotion call site — we cannot tolerate even
|
|
// ~80 ms of audible drift across an ownership flip, so the call site
|
|
// must opt out of the jitter gate.
|
|
const spy = vi.spyOn(player, "_mirrorParentMediaTime");
|
|
player._promoteToParentProxy?.();
|
|
const forcedCall = spy.mock.calls.find(([, opts]) => opts?.force === true);
|
|
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 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;
|
|
};
|
|
|
|
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("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.
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer loop end-state handling", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
iframe: HTMLIFrameElement;
|
|
play: () => void;
|
|
seek: (timeInSeconds: number) => void;
|
|
loop: boolean;
|
|
_duration: number;
|
|
_currentTime: number;
|
|
_paused: boolean;
|
|
_onMessage: (event: MessageEvent) => void;
|
|
};
|
|
|
|
let player: PlayerInternal;
|
|
let frameWindow: Window;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
frameWindow = window;
|
|
vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
|
|
Object.defineProperty(player.iframe, "contentWindow", {
|
|
configurable: true,
|
|
get: () => frameWindow,
|
|
});
|
|
document.body.appendChild(player);
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("wraps and keeps playing when a looping composition posts its final paused state", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
const play = vi.spyOn(player, "play");
|
|
player.loop = true;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(seek).toHaveBeenCalledWith(0);
|
|
expect(play).toHaveBeenCalled();
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("fires ended and stays paused when a non-looping composition posts its final paused state", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
const play = vi.spyOn(player, "play");
|
|
const ended = vi.fn();
|
|
player.addEventListener("ended", ended);
|
|
player.loop = false;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(seek).not.toHaveBeenCalled();
|
|
expect(play).not.toHaveBeenCalled();
|
|
expect(ended).toHaveBeenCalledTimes(1);
|
|
expect(player._paused).toBe(true);
|
|
});
|
|
|
|
it("play() seeks to 0 and replays when called after the video has ended", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
player.loop = false;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(player._paused).toBe(true);
|
|
seek.mockClear();
|
|
|
|
player.play();
|
|
|
|
expect(seek).toHaveBeenCalledWith(0);
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("play() does not seek to 0 when called mid-playback", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
player._duration = 4;
|
|
player._paused = true;
|
|
// Simulate mid-video position (frame 60 = 2s into a 4s video)
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 60,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
player.play();
|
|
|
|
expect(seek).not.toHaveBeenCalled();
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("clamps _currentTime to _duration when a state message reports a frame past the end", () => {
|
|
// Regression test: the postMessage state path previously set _currentTime
|
|
// without clamping, while the direct timeline path already clamped. A frame
|
|
// count slightly past the end (common on final-frame messages) would set
|
|
// _currentTime > _duration, causing the progress bar to overflow the
|
|
// scrubber track and the time display to show e.g. "0:05 / 0:04".
|
|
player._duration = 4; // 4s = 120 frames at 30fps
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 150, // 5s — past the 4s duration
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(player._currentTime).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer srcdoc attribute", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
iframe: HTMLIFrameElement;
|
|
_ready: boolean;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
it("includes srcdoc in observedAttributes", () => {
|
|
// `attributeChangedCallback` only fires for observed attributes. Without
|
|
// this, runtime srcdoc swaps from studio would silently drop on the floor.
|
|
const ctor = customElements.get("hyperframes-player") as
|
|
| (typeof HTMLElement & { observedAttributes: string[] })
|
|
| undefined;
|
|
expect(ctor).toBeDefined();
|
|
expect(ctor!.observedAttributes).toContain("srcdoc");
|
|
});
|
|
|
|
it("forwards an initial srcdoc attribute to the iframe on connect", () => {
|
|
// Studio's primary use case: render the player with composition HTML
|
|
// already in hand, no network round-trip. Setting the attribute before
|
|
// the element is connected must still apply on connect.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
const html = "<!doctype html><html><body>hello</body></html>";
|
|
player.setAttribute("srcdoc", html);
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("forwards a srcdoc attribute set after connect to the iframe", () => {
|
|
// The composition-switching flow: same player element, new HTML.
|
|
// Without `attributeChangedCallback` wiring this would no-op.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
|
|
const html = "<!doctype html><html><body>after connect</body></html>";
|
|
player.setAttribute("srcdoc", html);
|
|
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("resets _ready when srcdoc changes so onIframeLoad replays setup", () => {
|
|
// The ready flag gates probe intervals, controls hookup, and poster
|
|
// tear-down. Switching documents must invalidate it so the next `load`
|
|
// event re-runs that setup against the fresh window.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
player._ready = true;
|
|
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
|
|
expect(player._ready).toBe(false);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("removes iframe.srcdoc when the attribute is removed so src can take over", () => {
|
|
// Per HTML spec, iframe.srcdoc beats iframe.src whenever both are
|
|
// present. Studio's fetch-fail fallback path needs srcdoc cleared so
|
|
// setting src afterwards actually navigates to that URL.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
document.body.appendChild(player);
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
|
|
|
|
player.removeAttribute("srcdoc");
|
|
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("treats an empty-string srcdoc as a deliberate empty document, not removal", () => {
|
|
// `setAttribute("srcdoc", "")` and `removeAttribute("srcdoc")` send
|
|
// different signals from the caller — empty string means "load a blank
|
|
// doc," removal means "fall back to src." We have to distinguish them.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("srcdoc", "");
|
|
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe("");
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("forwards both src and srcdoc to the iframe and lets the browser arbitrate", () => {
|
|
// We deliberately don't strip src when srcdoc is set: the HTML spec
|
|
// already says srcdoc wins, and keeping both lets the browser fall back
|
|
// to src automatically if the embed re-renders without srcdoc.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
player.setAttribute("src", "/api/projects/foo/preview");
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview");
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe("<!doctype html><html></html>");
|
|
|
|
player.remove();
|
|
});
|
|
});
|
|
|
|
// ── Volume / Mute controls ──
|
|
|
|
describe("HyperframesPlayer volume and mute", () => {
|
|
let player: HTMLElement & {
|
|
muted: boolean;
|
|
volume: number;
|
|
iframeElement: HTMLIFrameElement;
|
|
};
|
|
let mockAudio: {
|
|
preload: string;
|
|
src: string;
|
|
muted: boolean;
|
|
volume: number;
|
|
playbackRate: number;
|
|
currentTime: number;
|
|
load: ReturnType<typeof vi.fn>;
|
|
play: ReturnType<typeof vi.fn>;
|
|
pause: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
|
|
mockAudio = {
|
|
preload: "",
|
|
src: "",
|
|
muted: false,
|
|
volume: 1,
|
|
playbackRate: 1,
|
|
currentTime: 0,
|
|
load: vi.fn(),
|
|
play: vi.fn().mockResolvedValue(undefined),
|
|
pause: vi.fn(),
|
|
};
|
|
vi.spyOn(globalThis, "Audio").mockImplementation(
|
|
() => mockAudio as unknown as HTMLAudioElement,
|
|
);
|
|
|
|
player = document.createElement("hyperframes-player") as typeof player;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("defaults volume to 1", () => {
|
|
document.body.appendChild(player);
|
|
expect(player.volume).toBe(1);
|
|
});
|
|
|
|
it("sets volume on parent media when audio-src is configured", () => {
|
|
player.setAttribute("volume", "0.5");
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
expect(mockAudio.volume).toBe(0.5);
|
|
});
|
|
|
|
it("updates parent media volume when volume attribute changes", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("volume", "0.3");
|
|
expect(mockAudio.volume).toBe(0.3);
|
|
});
|
|
|
|
it("clamps volume to [0, 1]", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.volume = 1.5;
|
|
expect(player.volume).toBe(1);
|
|
|
|
player.volume = -0.5;
|
|
expect(player.volume).toBe(0);
|
|
});
|
|
|
|
it("dispatches volumechange event when volume changes", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const handler = vi.fn();
|
|
player.addEventListener("volumechange", handler);
|
|
|
|
player.setAttribute("volume", "0.7");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("muted property toggles the muted attribute", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.muted = true;
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
|
|
player.muted = false;
|
|
expect(player.hasAttribute("muted")).toBe(false);
|
|
});
|
|
|
|
it("sends set-volume control to iframe", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const postMessageSpy = vi.fn();
|
|
Object.defineProperty(player.iframeElement, "contentWindow", {
|
|
value: { postMessage: postMessageSpy },
|
|
configurable: true,
|
|
});
|
|
|
|
player.setAttribute("volume", "0.6");
|
|
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action: "set-volume",
|
|
volume: 0.6,
|
|
}),
|
|
"*",
|
|
);
|
|
});
|
|
|
|
it("controls bar shows mute button when controls are enabled", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const muteBtn = shadow.querySelector(".hfp-mute-btn");
|
|
expect(muteBtn).toBeTruthy();
|
|
expect(muteBtn?.getAttribute("aria-label")).toBe("Mute");
|
|
});
|
|
|
|
it("controls bar shows volume slider when controls are enabled", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const slider = shadow.querySelector(".hfp-volume-slider");
|
|
expect(slider).toBeTruthy();
|
|
});
|
|
|
|
it("volume slider has ARIA slider attributes", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const slider = shadow.querySelector(".hfp-volume-slider")!;
|
|
expect(slider.getAttribute("role")).toBe("slider");
|
|
expect(slider.getAttribute("aria-label")).toBe("Volume");
|
|
expect(slider.getAttribute("aria-valuemin")).toBe("0");
|
|
expect(slider.getAttribute("aria-valuemax")).toBe("100");
|
|
expect(slider.getAttribute("aria-valuenow")).toBe("100");
|
|
expect(slider.getAttribute("tabindex")).toBe("0");
|
|
});
|
|
|
|
it("dispatches volumechange when muted toggles (HTML5 spec)", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const handler = vi.fn();
|
|
player.addEventListener("volumechange", handler);
|
|
|
|
player.muted = true;
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
player.muted = false;
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("muted icon differs from volume=0 unmuted icon", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const muteBtn = shadow.querySelector(".hfp-mute-btn")!;
|
|
|
|
player.setAttribute("volume", "0");
|
|
const zeroVolumeHtml = muteBtn.innerHTML;
|
|
|
|
player.muted = true;
|
|
const mutedHtml = muteBtn.innerHTML;
|
|
|
|
expect(zeroVolumeHtml).not.toBe(mutedHtml);
|
|
});
|
|
});
|