Skip to content
Draft
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
5 changes: 4 additions & 1 deletion docs/packages/player.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ playback state, position and speed.
| `srcdoc` | string | — | Composition HTML already available as a string |
| `width` | number | 1920 | Native composition width used for aspect ratio |
| `height` | number | 1080 | Native composition height used for aspect ratio |
| `controls` | boolean | false | Show playback, scrub, speed, time, and volume controls |
| `controls` | boolean | false | Show playback, scrub, speed, time, volume, and fullscreen controls |
| `autoplay` | boolean | false | Start when the composition is ready |
| `loop` | boolean | false | Restart at the end |
| `muted` | boolean | false | Mute audio |
Expand All @@ -67,12 +67,15 @@ playback state, position and speed.
| `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire |
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses, so the page can handle it |
| `disable-fullscreen` | boolean | false | Hide the fullscreen button in the controls (`disableFullscreen`) |
| `no-load-timeout` | boolean | false | A slow load never fails at 8 s, so the page owns the deadline; script and runtime errors still fail at once |
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused (`rangeStart`) |
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it (`rangeEnd`) |

`range-start` and `range-end` play the moment [start, end) of the film. The player parks on `range-start` at `ready`, and again when a paused playhead falls outside a new range; `play()` from outside the range starts there. At the end it holds the last frame before `range-end` and fires `ended`, or wraps to `range-start` with `loop`. `currentTime` at `ended` is then inside that frame, not `range-end` itself, so listen for `ended` rather than comparing times. A current runtime stops on that frame itself; video and `__timelines` players stop one clock tick early, predicted from the smaller of their last two steps, and a video in a background tab checks on its own `timeupdate`; an older runtime stops when the player sees its time pass the end. `currentTime` and `duration` stay in film time. A range past the film is cut to its end and an empty or negative one is ignored; both fire `rangeclamped`.

The fullscreen button fills the screen with the whole player, controls included. It appears only where the page can go fullscreen, so not on iPhone Safari and not inside a cross-origin iframe without `allow="fullscreen"`. It follows every exit, including Esc. Set `disable-fullscreen` to hide it. A refused request fires the browser's `fullscreenerror` on the player.

Player also accepts `shader-capture-scale` and `shader-loading` for previewing
projects that use shader transitions. These are preview controls, not composition
authoring attributes.
Expand Down
6 changes: 5 additions & 1 deletion packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ The player loads the composition in a sandboxed iframe, auto-detects its dimensi

Add or remove `controls` at any time. When shown, the controls reflect the current playback state, position and speed.

The fullscreen button fills the screen with the whole player, controls included. It appears only where the page can go fullscreen, so not on iPhone Safari and not inside a cross-origin iframe without `allow="fullscreen"`. It follows every exit, including Esc. Set `disable-fullscreen` to hide it. A refused request fires the browser's `fullscreenerror` on the player.

### With a framework

```typescript
Expand Down Expand Up @@ -76,7 +78,7 @@ Show a static image before playback starts:
| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) |
| `width` | number | 1920 | Composition width in pixels (aspect ratio) |
| `height` | number | 1080 | Composition height in pixels (aspect ratio) |
| `controls` | boolean | false | Show play/pause, scrubber, and time display |
| `controls` | boolean | false | Show play/pause, scrubber, time, volume, speed, and fullscreen controls |
| `muted` | boolean | false | Mute audio playback |
| `audio-locked` | boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on |
| `poster` | string | — | Image URL shown before playback starts |
Expand All @@ -88,6 +90,7 @@ Show a static image before playback starts:
| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire |
| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) |
| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses (host overlays own clicks) |
| `disable-fullscreen` | boolean | false | Hide the fullscreen button; `requestFullscreen()` still works |
| `no-load-timeout` | boolean | false | Slow loads never fail at 8 s; the page owns the deadline. Errors still fail |
| `range-start` | number | — | Film second where playback starts, loops back to and parks when paused |
| `range-end` | number | — | Film second the range ends before: it stops or loops on the frame before it |
Expand Down Expand Up @@ -154,6 +157,7 @@ player.ready; // boolean (read-only)
player.compositionWidth; // number (read-only), the composition's width
player.compositionHeight; // number (read-only), the composition's height
player.disableClickToPlay; // boolean (read/write)
player.disableFullscreen; // boolean (read/write)
player.playbackRate; // number (read/write)
player.muted; // boolean (read/write)
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
Expand Down
176 changes: 175 additions & 1 deletion packages/player/src/controls.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { type ControlsCallbacks, createControls } from "./controls";
import { FULLSCREEN_ENTER_ICON, FULLSCREEN_EXIT_ICON } from "./styles";

function noopCallbacks(): ControlsCallbacks {
return {
Expand Down Expand Up @@ -165,3 +166,176 @@ describe("createControls idle hide", () => {
vi.useRealTimers();
});
});

describe("createControls fullscreen", () => {
const mounted: { host: HTMLElement; api: ReturnType<typeof createControls> }[] = [];

function newHost(): HTMLElement {
return document.body.appendChild(document.createElement("div"));
}

function mount(host: HTMLElement) {
const api = createControls(host, noopCallbacks());
mounted.push({ host, api });
return api;
}

function serialized(html: string): string {
const template = document.createElement("template");
template.innerHTML = html;
return template.innerHTML;
}

function fullscreenButton(host: HTMLElement): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>(".hfp-fullscreen-btn");
if (!button) throw new Error("no fullscreen button");
return button;
}

function fakeFullscreen(host: HTMLElement) {
let active = false;
const announce = (target: EventTarget) =>
target.dispatchEvent(new Event("fullscreenchange", { bubbles: true, composed: true }));
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
vi.spyOn(host, "matches").mockImplementation((selector: string) =>
selector === ":fullscreen" ? active : Element.prototype.matches.call(host, selector),
);
const requestFullscreen = vi.fn(async () => {
active = true;
announce(host);
});
const exitFullscreen = vi.fn(async () => {
active = false;
announce(host);
});
host.requestFullscreen = requestFullscreen;
document.exitFullscreen = exitFullscreen;
return {
requestFullscreen,
exitFullscreen,
outside: (next: boolean) => {
active = next;
announce(document);
},
};
}

afterEach(() => {
for (const { host, api } of mounted.splice(0)) {
api.destroy();
host.remove();
}
Reflect.deleteProperty(document, "fullscreenEnabled");
Reflect.deleteProperty(document, "fullscreenElement");
Reflect.deleteProperty(document, "exitFullscreen");
vi.restoreAllMocks();
});

it("leaves the button out where the page cannot go fullscreen", () => {
const plain = newHost();
mount(plain);
expect(plain.querySelector(".hfp-fullscreen-btn")).toBeNull();

Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: false });
const refused = newHost();
mount(refused);
expect(refused.querySelector(".hfp-fullscreen-btn")).toBeNull();
});

it("adds a Full screen button as the last control", () => {
const host = newHost();
fakeFullscreen(host);
mount(host);
const button = fullscreenButton(host);

expect(host.querySelector(".hfp-controls")?.lastElementChild).toBe(button);
expect(button.getAttribute("aria-label")).toBe("Full screen");
expect(button.innerHTML).toBe(serialized(FULLSCREEN_ENTER_ICON));
});

it("puts the host in fullscreen and back, naming the next action", () => {
const host = newHost();
const { requestFullscreen, exitFullscreen } = fakeFullscreen(host);
mount(host);
const button = fullscreenButton(host);

button.click();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen.mock.contexts[0]).toBe(host);
expect(button.getAttribute("aria-label")).toBe("Exit full screen");
expect(button.innerHTML).toBe(serialized(FULLSCREEN_EXIT_ICON));

button.click();
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(button.getAttribute("aria-label")).toBe("Full screen");
expect(button.innerHTML).toBe(serialized(FULLSCREEN_ENTER_ICON));
});

it("follows fullscreen changes it did not cause", () => {
const host = newHost();
const { requestFullscreen, exitFullscreen, outside } = fakeFullscreen(host);
mount(host);
const button = fullscreenButton(host);

outside(true);
expect(button.getAttribute("aria-label")).toBe("Exit full screen");
outside(false);
expect(button.getAttribute("aria-label")).toBe("Full screen");

Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: document.createElement("div"),
});
button.click();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen.mock.contexts[0]).toBe(host);
expect(exitFullscreen).not.toHaveBeenCalled();
});

it("keeps its name when the browser refuses", async () => {
const host = newHost();
fakeFullscreen(host);
let requests = 0;
host.requestFullscreen = () => {
requests += 1;
return Promise.reject(new TypeError("Permissions check failed"));
};
mount(host);
const button = fullscreenButton(host);

const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
button.click();
await new Promise((resolve) => setTimeout(resolve, 0));
process.off("unhandledRejection", unhandled);

expect(requests).toBe(1);
expect(unhandled).not.toHaveBeenCalled();
expect(button.getAttribute("aria-label")).toBe("Full screen");
});

it("starts as Exit full screen when built during fullscreen", () => {
const host = newHost();
fakeFullscreen(host).outside(true);
mount(host);
const button = fullscreenButton(host);

expect(button.getAttribute("aria-label")).toBe("Exit full screen");
expect(button.innerHTML).toBe(serialized(FULLSCREEN_EXIT_ICON));
});

it("removes its fullscreenchange listener on destroy", () => {
const host = newHost();
fakeFullscreen(host);
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
const api = createControls(host, noopCallbacks());

const added = addSpy.mock.calls.find(([type]) => type === "fullscreenchange");
expect(added).toBeDefined();
api.destroy();

expect(removeSpy).toHaveBeenCalledWith("fullscreenchange", added?.[1]);
host.remove();
});
});
30 changes: 30 additions & 0 deletions packages/player/src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
VOLUME_HIGH_ICON,
VOLUME_LOW_ICON,
VOLUME_MUTED_ICON,
FULLSCREEN_ENTER_ICON,
FULLSCREEN_EXIT_ICON,
} from "./styles.js";

export interface ControlsCallbacks {
Expand Down Expand Up @@ -50,6 +52,31 @@ export function formatTime(seconds: number): string {
return `${m}:${sec.toString().padStart(2, "0")}`;
}

function createFullscreenButton(
host: HTMLElement,
): { button: HTMLButtonElement; destroy: () => void } | null {
if (!document.fullscreenEnabled) return null;
const button = document.createElement("button");
button.className = "hfp-fullscreen-btn";
button.type = "button";
const render = () => {
const active = host.matches(":fullscreen");
button.innerHTML = active ? FULLSCREEN_EXIT_ICON : FULLSCREEN_ENTER_ICON;
button.setAttribute("aria-label", active ? "Exit full screen" : "Full screen");
};
button.addEventListener("click", (e) => {
e.stopPropagation();
const change = host.matches(":fullscreen")
? document.exitFullscreen()
: host.requestFullscreen();
// A refusal also fires fullscreenerror at the player, where the page can observe it.
change.catch(() => {});
});
document.addEventListener("fullscreenchange", render);
render();
return { button, destroy: () => document.removeEventListener("fullscreenchange", render) };
}

export function createControls(
parent: ShadowRoot | HTMLElement,
callbacks: ControlsCallbacks,
Expand Down Expand Up @@ -350,6 +377,8 @@ export function createControls(
};

const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
const fullscreen = createFullscreenButton(host);
if (fullscreen) controls.appendChild(fullscreen.button);
const onHostActivity = () => reveal();
const onHostMouseLeave = () => {
if (canHide()) controls.classList.add("hfp-hidden");
Expand Down Expand Up @@ -419,6 +448,7 @@ export function createControls(
host.removeEventListener("focusin", onHostActivity);
host.removeEventListener("mouseleave", onHostMouseLeave);
if (hideTimeout) clearTimeout(hideTimeout);
fullscreen?.destroy();
controls.remove();
},
};
Expand Down
61 changes: 61 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3550,6 +3550,67 @@ describe("HyperframesPlayer click-to-play", () => {
});
});

describe("HyperframesPlayer fullscreen", () => {
type FullscreenPlayer = HTMLElement & {
play: () => void;
disableFullscreen: boolean;
};

let player: FullscreenPlayer;

function fullscreenButton(): HTMLButtonElement {
const button = player.shadowRoot?.querySelector<HTMLButtonElement>(".hfp-fullscreen-btn");
if (!button) throw new Error("no fullscreen button");
return button;
}

beforeEach(async () => {
await import("./hyperframes-player.js");
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
player = document.createElement("hyperframes-player") as FullscreenPlayer;
player.setAttribute("controls", "");
document.body.appendChild(player);
});

afterEach(() => {
player.remove();
Reflect.deleteProperty(document, "fullscreenEnabled");
vi.restoreAllMocks();
});

it("disableFullscreen property toggles the disable-fullscreen attribute", () => {
player.disableFullscreen = true;
expect(player.hasAttribute("disable-fullscreen")).toBe(true);

player.disableFullscreen = false;
expect(player.hasAttribute("disable-fullscreen")).toBe(false);
});

it("fullscreens the player element, and the click does not play", () => {
const requestFullscreen = vi.fn(async () => {});
player.requestFullscreen = requestFullscreen;
const play = vi.spyOn(player, "play").mockImplementation(() => undefined);

fullscreenButton().click();

expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(requestFullscreen.mock.contexts[0]).toBe(player);
expect(play).not.toHaveBeenCalled();
});

it("disable-fullscreen hides the button", () => {
expect(getComputedStyle(fullscreenButton()).display).not.toBe("none");

player.remove();
player = document.createElement("hyperframes-player") as FullscreenPlayer;
player.setAttribute("controls", "");
player.setAttribute("disable-fullscreen", "");
document.body.appendChild(player);

expect(getComputedStyle(fullscreenButton()).display).toBe("none");
});
});

describe("HyperframesPlayer retained runtime data", () => {
interface RuntimeDataPlayer extends HTMLElement {
iframeElement: HTMLIFrameElement;
Expand Down
8 changes: 8 additions & 0 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const ASSETS_LOADING_UI_ATTR = "assets-loading-ui";
const BUFFERING_ATTR = "buffering";
const LOW_POWER_IDLE_ATTR = "low-power-idle";
const DISABLE_CLICK_TO_PLAY_ATTR = "disable-click-to-play";
const DISABLE_FULLSCREEN_ATTR = "disable-fullscreen";
const RANGE_START_ATTR = "range-start";
const RANGE_END_ATTR = "range-end";
// paint-and-idle now always has a frame to wait on, so the overlay would
Expand Down Expand Up @@ -718,6 +719,13 @@ class HyperframesPlayer extends HTMLElement {
this.toggleAttribute(DISABLE_CLICK_TO_PLAY_ATTR, disabled);
}

get disableFullscreen() {
return this.hasAttribute(DISABLE_FULLSCREEN_ATTR);
}
set disableFullscreen(disabled: boolean) {
this.toggleAttribute(DISABLE_FULLSCREEN_ATTR, disabled);
}

get loop() {
return this.hasAttribute("loop");
}
Expand Down
Loading
Loading