Files
hyperframes/packages/player
James RussoandClaude Opus 4.8 7517f6ac86 feat(slideshow): per-slide autoplay (manual-advance, opt-in) (#1708)
* feat(slideshow): per-slide autoplay (manual-advance, opt-in)

Adds an opt-in `autoplay` flag to slideshow slides: when the presenter lands
on a video slide, its `<video>` plays from the start. The slideshow still
holds and never auto-advances — the presenter clicks Next when ready. This
covers compositions whose own controls can't be clicked (the player renders
the composition pointer-events:none).

Plumbing (done, tested):
- core: `SlideRef.autoplay?: boolean`, parsed + validated in parseSlideshow
  (a non-boolean autoplay rejects the manifest); carried through resolve.
- controller: optional `PlayerPort.playSceneMedia(sceneId)`, fired only on
  forward `enterSlide` for autoplay slides (not resume/back/sync, so the
  audience — which mirrors the presenter's media events — isn't double-driven).
- component: `playSceneDocumentMedia` reaches the same-origin composition
  iframe, finds the scene's `<video>`, and asserts playback; `stopMedia`
  (already wired on slide change) resets it. An autoplay token cancels a
  pending start when the slide changes.
- tests: controller autoplay behavior + parser flag round-trip/validation
  (131 player + 22 core slideshow tests pass).

KNOWN LIMITATION — runtime media-start needs the player media model (@vance):
On current main the clip<->timeline binding from #1601 keeps every clip synced
and *paused* to the held timeline frame, which wins against playSceneMedia's
play() — so the clip does not actually start on main yet (it does on the
pre-#1601 player). The correct fix is a sanctioned "let this clip free-run
while the timeline holds" path in the player/runtime media controller. Flagging
for Vance to wire the start into the #1601 media model (or rebase onto it) when
back. The plumbing above is the stable surface that hook plugs into.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(slideshow): address code-review on per-slide autoplay

- guard playSceneDocumentMedia behind resolveMode() !== "audience": the
  audience mirrors the presenter's media events, so it must not independently
  drive its own copy of the clip.
- drop the per-enter window pointerdown/keydown "gesture retry" listeners,
  which leaked when muted autoplay succeeded without a gesture. The poll already
  re-asserts play(), so a gesture within the window is picked up next tick.
- stop polling once the clip is advancing across two ticks (was re-asserting
  play() for the full window even after playback was confirmed).
- cancel any in-flight autoplay loop on disconnectedCallback (bump the token).
- split the poll into findSceneVideo + stepAutoplay helpers (keeps each small).
- fix the enterSlide comment: autoplay fires from enterSlide (next/prev/
  goToSlide), not resumeSlide (back/backToMain/syncTo).
- parser: isOptionalBoolean type guard instead of a one-off helper; drop `as`
  assertions in the new controller test.

131 player + 22 core slideshow tests pass; lint/format/typecheck/fallow clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(slideshow): autoplay skill guidance + address review nits

Addresses review feedback on #1708:
- skill: document per-slide `autoplay` in the slideshow standalone-harness
  reference — when to use it (video is the slide's primary content, its end is
  the advance cue) vs not (background/ambient loops, footage talked over), per
  Vance's guidance, before merge.
- play() rejection is no longer blanket-swallowed: AbortError (timeline-sync
  seek interrupt) and NotAllowedError (gesture-gated autoplay) are expected and
  ignored; any other rejection is surfaced once via console.warn (Via nit 1).
- clarify in the SlideRef.autoplay doc that it plays the scene's FIRST <video>
  (Via nit 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:47:54 -07:00
..
2026-06-25 11:54:10 -04:00

@hyperframes/player

Embeddable web component for playing HyperFrames compositions. Zero dependencies, works with any framework.

Install

npm install @hyperframes/player

Or load directly via CDN:

<script type="module" src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>

If you need a classic <script> tag instead of ESM, use the explicit global build:

<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player/dist/hyperframes-player.global.js"></script>

Usage

<hyperframes-player src="./my-composition/index.html" controls></hyperframes-player>

The player loads the composition in a sandboxed iframe, auto-detects its dimensions and duration, and scales it responsively to fit the container.

With a framework

import "@hyperframes/player";

// The custom element is now registered — use it in your markup
// React: <hyperframes-player src="..." controls />
// Vue:   <hyperframes-player :src="url" controls />

Poster image

Show a static image before playback starts:

<hyperframes-player
  src="./composition/index.html"
  poster="./thumbnail.jpg"
  controls
></hyperframes-player>

Attributes

Attribute Type Default Description
src string URL to the composition HTML file
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
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
playback-rate number 1 Speed multiplier (0.5 = half, 2 = double)
autoplay boolean false Start playing when ready
loop boolean false Restart when the composition ends
shader-capture-scale number Shader transition snapshot scale forwarded to browser previews (0.25-1)
shader-loading composition | player | none composition Controls shader transition prep loading UI ownership

Shader transition previews

When a composition uses @hyperframes/shader-transitions, the player can own preview-only shader capture settings:

<hyperframes-player
  src="./composition/index.html"
  shader-capture-scale="1"
  shader-loading="player"
  controls
></hyperframes-player>

shader-loading="player" shows the player-owned transition-prep overlay from shader progress messages. composition leaves direct composition fallback behavior alone, and none suppresses the loader.

Audio lock (host-mandated silent playback)

audio-locked forces muted on and hides the volume controls, with no UI path for the viewer to turn sound back on. Use it when embedding in a chat host (Claude.ai, ChatGPT, etc.) where audio must stay off regardless of viewer intent. Setting muted directly is not enough — viewers can flip it back via the controls bar.

Removing audio-locked only unhides the controls; it does not auto-unmute. Callers manage muted explicitly after unlocking.

Host-environment fallback. Some host renderers — notably the Claude desktop Electron client — strip unknown custom-element attributes before they reach the DOM, defeating the attribute. As a safety net, the player also self-imposes the lock when it detects such an environment via navigator.userAgent, so audio stays muted even if the attribute never arrives. The public audioLocked property still reflects only the attribute, so external consumers (e.g. host widgets that mirror state) are not affected by the fallback.

Mobile audio

Mobile browsers block audio.play() inside iframes when the user gesture happened in the parent frame (the User Activation spec does not propagate activation across frame boundaries via postMessage).

The player handles this automatically for same-origin iframes (the default — sandbox includes allow-same-origin):

  1. When the composition is ready, the player extracts all timed media (audio[data-start], video[data-start]) from the iframe DOM and creates parent-frame copies.
  2. The iframe originals are disabled (src and data-start removed) so the runtime doesn't try to play them.
  3. When play() is called (from a user gesture), parent media .play() runs synchronously in the gesture call stack, satisfying mobile autoplay policy.
  4. Both parent media and the GSAP timeline start simultaneously and free-run — no active sync needed since both are real-time systems.

No changes are required by consumers — this works out of the box.

The optional audio-src attribute can be used to start preloading a primary audio track before the iframe loads (useful on slow connections), but is not required for mobile playback.

JavaScript API

const player = document.querySelector("hyperframes-player");

// Playback
player.play();
player.pause();
player.seek(2.5); // jump to 2.5 seconds

// Properties
player.currentTime; // number (read/write)
player.duration; // number (read-only)
player.paused; // boolean (read-only)
player.ready; // boolean (read-only)
player.playbackRate; // number (read/write)
player.muted; // boolean (read/write)
player.audioLocked; // boolean (read/write) — force-mute + hide volume controls
player.loop; // boolean (read/write)
player.shaderCaptureScale; // number (read/write)
player.shaderLoading; // "composition" | "player" | "none" (read/write)

// Inner iframe access (for advanced consumers — see "Advanced: iframe access" below)
player.iframeElement; // HTMLIFrameElement (read-only)

Advanced: iframe access

The composition runs inside a sandboxed <iframe> in the player's Shadow DOM. For most use cases you don't need direct access — the JavaScript API above is enough. But if you're building an editor, recorder, or custom timeline that needs to inspect the composition's DOM or read its __player / __timelines runtime objects, use the iframeElement getter:

const player = document.querySelector("hyperframes-player");
const iframe = player.iframeElement;

// Now you can reach into the composition's DOM and runtime
iframe.contentDocument.querySelectorAll("[data-composition-id]");
iframe.contentWindow.__timelines;

This is the canonical way to bridge the player into tools like @hyperframes/studio. The studio exports a resolveIframe helper that works with both iframe refs and web-component refs:

import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";

const { iframeRef } = useTimelinePlayer();
const player = document.createElement("hyperframes-player");
player.setAttribute("src", src);
container.appendChild(player);

// Forward the inner iframe so useTimelinePlayer can drive play/pause/seek.
iframeRef.current = resolveIframe(player);

React: declarative ref pattern

If you prefer JSX over imperative element creation, attach a ref directly to the web component and resolve the iframe inside an effect:

import "@hyperframes/player";
import type { HyperframesPlayer } from "@hyperframes/player";
import { useTimelinePlayer, resolveIframe } from "@hyperframes/studio";

function StudioPreview({ src }: { src: string }) {
  const { iframeRef, onIframeLoad } = useTimelinePlayer();
  const playerRef = useRef<HyperframesPlayer>(null);

  useEffect(() => {
    iframeRef.current = resolveIframe(playerRef.current);
  });

  return <hyperframes-player ref={playerRef} src={src} onLoad={onIframeLoad} />;
}

Heads up — common gotcha

If you pass the <hyperframes-player> element itself (not iframeElement) into a hook that expects an <iframe>, every .contentWindow / .contentDocument access returns null because the iframe lives inside the player's Shadow DOM. Always extract iframeElement first, or use resolveIframe from @hyperframes/studio which handles both iframe and web-component hosts transparently.

Events

Event Detail Fired when
ready { duration } Composition loaded and duration determined
play Playback started
pause Playback paused
timeupdate { currentTime } Playback position changed (~10 fps)
ended Reached the end (when not looping)
error { message } Composition failed to load
shadertransitionstate { compositionId, state } Shader transition cache/capture progress
player.addEventListener("ready", (e) => {
  console.log(`Duration: ${e.detail.duration}s`);
});

player.addEventListener("ended", () => {
  console.log("Done!");
});

Sizing

The player fills its container and scales the composition to fit while preserving aspect ratio. Set a size on the element or its parent:

hyperframes-player {
  width: 100%;
  max-width: 800px;
  aspect-ratio: 16 / 9;
}

The width and height attributes define the composition's native resolution for aspect ratio calculation — they don't set the player's display size.

How it works

The player renders compositions in a sandboxed <iframe> inside a Shadow DOM. It communicates with the HyperFrames runtime via postMessage. If the composition has GSAP timelines (window.__timelines) but no runtime, the player auto-injects it from CDN.

Distribution

Format File Use case
ESM hyperframes-player.js Bundlers (Vite, webpack, etc.)
CJS hyperframes-player.cjs Node.js / require()
IIFE hyperframes-player.global.js <script> tag, CDN

All formats are minified with source maps. TypeScript definitions included.

License

MIT