From 357fd272e86da44589fde8408f24d20d48acddbc Mon Sep 17 00:00:00 2001 From: robinebers Date: Sat, 14 Nov 2026 06:01:10 +0000 Subject: [PATCH] feat(player): add a fullscreen button to the preview player The button puts the whole player, controls included, in fullscreen and follows every fullscreen change. Set disable-fullscreen to hide it. It raises the player's package size budget. With pnpm 10.17.1, main packs to 158,705 bytes (544,891 unpacked) and this branch to 160,137 (549,402). --- docs/packages/player.mdx | 5 +- packages/player/README.md | 6 +- packages/player/src/controls.test.ts | 176 +++++++++++++++++- packages/player/src/controls.ts | 30 +++ .../player/src/hyperframes-player.test.ts | 61 ++++++ packages/player/src/hyperframes-player.ts | 8 + packages/player/src/styles.ts | 16 +- scripts/package-size-budgets.json | 4 +- 8 files changed, 298 insertions(+), 8 deletions(-) diff --git a/docs/packages/player.mdx b/docs/packages/player.mdx index 5fdfa28926e..65197803a3a 100644 --- a/docs/packages/player.mdx +++ b/docs/packages/player.mdx @@ -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 | @@ -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. diff --git a/packages/player/README.md b/packages/player/README.md index c6f89d9c106..957db72979d 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -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 @@ -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 | @@ -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 | @@ -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 diff --git a/packages/player/src/controls.test.ts b/packages/player/src/controls.test.ts index d41e29565cc..4e0a63d4e03 100644 --- a/packages/player/src/controls.test.ts +++ b/packages/player/src/controls.test.ts @@ -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 { @@ -165,3 +166,176 @@ describe("createControls idle hide", () => { vi.useRealTimers(); }); }); + +describe("createControls fullscreen", () => { + const mounted: { host: HTMLElement; api: ReturnType }[] = []; + + 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(".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(); + }); +}); diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index b6485b63b92..9cee70112c6 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -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 { @@ -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, @@ -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"); @@ -419,6 +448,7 @@ export function createControls( host.removeEventListener("focusin", onHostActivity); host.removeEventListener("mouseleave", onHostMouseLeave); if (hideTimeout) clearTimeout(hideTimeout); + fullscreen?.destroy(); controls.remove(); }, }; diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 56f84e3a48b..3095ab5fd60 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -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(".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; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 3c257631bce..54e5ba5960f 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -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 @@ -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"); } diff --git a/packages/player/src/styles.ts b/packages/player/src/styles.ts index 8f37502af2d..be14144bace 100644 --- a/packages/player/src/styles.ts +++ b/packages/player/src/styles.ts @@ -506,7 +506,8 @@ export const PLAYER_STYLES = /* css */ ` gap: 0; } - .hfp-mute-btn { + .hfp-mute-btn, + .hfp-fullscreen-btn { background: none; border: none; color: var(--hfp-color, #fff); @@ -520,15 +521,22 @@ export const PLAYER_STYLES = /* css */ ` flex-shrink: 0; } - .hfp-mute-btn:hover { + .hfp-mute-btn:hover, + .hfp-fullscreen-btn:hover { opacity: 0.8; } .hfp-mute-btn svg, - .hfp-mute-btn svg * { + .hfp-mute-btn svg *, + .hfp-fullscreen-btn svg, + .hfp-fullscreen-btn svg * { pointer-events: none; } + :host([disable-fullscreen]) .hfp-fullscreen-btn { + display: none; + } + .hfp-volume-slider-wrap { width: 0; overflow: hidden; @@ -570,6 +578,8 @@ export const PAUSE_ICON = ``; export const VOLUME_LOW_ICON = ``; export const VOLUME_MUTED_ICON = ``; +export const FULLSCREEN_ENTER_ICON = ``; +export const FULLSCREEN_EXIT_ICON = ``; /** * Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 1523d7e3d6c..b1687b4fddf 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -32,8 +32,8 @@ "unpacked": 885314 }, "@hyperframes/player": { - "packed": 157145, - "unpacked": 541601 + "packed": 160200, + "unpacked": 549500 }, "@hyperframes/producer": { "packed": 890000,