# Custom Interactive Workout Player Guide

> Reference implementation for building a custom workout player on top of the Hyperhuman Content API. Mirrors the Hyperhuman first-party players byte-for-byte.

This guide is for teams shipping their own UX (mobile apps, connected hardware, premium coaching experiences) instead of using the **Embedded Player** (single-workout iframes on `team.hyperhuman.cc`) documented in the public API overview. For a **hosted library grid** (browse many workouts or programs in an iframe on the member web app), use the **Branded library embed** section in that same overview — it is not the playlist API documented here. If you only need a drop-in single-workout player, use the Embedded Player and skip this guide.

The contract you build against is small:

| Endpoint | Returns |
|----------|---------|
| `GET /v1/workouts/{workoutId}` | Workout metadata (name, duration, difficulty, `videoAudioMode`, `mixBgMusic`, presentation flags, branding hints). |
| `GET /v1/workouts/{workoutId}/playlist?locale={locale}` | Ordered segments with presigned progressive MP4 `url` values, public unsigned S3 JPEG `poster`/`thumbnail` on exercise-family segments, voice instructions, beep cues, and timing. Drives the player. |
| `GET /v1/workouts/{workoutId}/music` | Shuffled PulseMix pool for a **persisted** workout id (pre-signed URLs, ~7-day expiration). Not available for ephemeral AI-generate / AI-adapt / JSON-receiver payloads without a saved id. |

> **Scope:** This guide is for **persisted library workouts** (`GET /workouts/{id}` + `/playlist` + optional `/music`). AI **workout generate** and **workout adapt** return ephemeral `{ name, instances[] }` JSON with **no** top-level workout id — play those via the JSON-receiver embed or a custom player built from `instances` + per-exercise media; do **not** call `/playlist` or `/music` for that response. Workout generate may also include `methodology` / `inspection` (Generation Inspector) — persist those if you need an audit trail; they are not player inputs and are not replayed later. On this API, generate applies Programming DNA only when you send `methodologyProfileId`. Adapt does not accept that field.

Everything below is built on top of these three endpoints plus the session tracking endpoints (`POST /v1/workouts/{workoutId}/sessions/start`, `PATCH .../{sessionId}`, `POST .../sessions/end`, `POST .../sessions/complete`, `POST /v1/workouts/{workoutId}/feedback`). `end` and `complete` are distinct: `end` closes the session keeping the last PATCHed progress (abandon), `complete` forces 100% (natural finish or "mark as done").

---

## Component overview

A robust custom player has five collaborating units. Names are illustrative; structure your codebase as you like.

| Component | Responsibility |
|-----------|----------------|
| **PlaylistManager** | Loads workout + playlist, normalizes segments, exposes `current`, `next`, `previous`, `segmentAt(index)`. |
| **MediaController** | Plays the segment video, voice instructions, beeps, and background music; honors the `videoAudioMode` matrix; handles browser audio policy gestures. |
| **ProgressTracker** | Computes elapsed and remaining time per segment and overall; emits progress events. |
| **NavigationHandler** | User intents (play, pause, next, previous, restart, done) plus auto-advance when a segment ends. |
| **SessionReporter** | Calls the session-tracking endpoints (`start`, periodic `PATCH`, `end`, `feedback`). |

---

## 1. Boot sequence

1. Fetch the workout document (`GET /v1/workouts/{workoutId}`). Cache `videoAudioMode`, `mixBgMusic`, `presentationStyle`, and brand overrides.
2. Fetch the playlist (`GET /v1/workouts/{workoutId}/playlist?locale=en-US`).
3. If `mixBgMusic !== false` and `videoAudioMode !== 'none'`, fetch the music playlist (`GET /v1/workouts/{workoutId}/music`).
4. Start a session: `POST /v1/workouts/{workoutId}/sessions/start` -> store `sessionId`.
5. Initialize the MediaController with the first segment but do not auto-play. Browsers block sound autoplay without a user gesture; show a "Start" button or trigger play from the same click that started the session.

---

## 2. Audio policy matrix

Each workout has a `videoAudioMode` (see `GET /v1/workouts/{workoutId}`) that controls which audio tracks the player must mix. Mirror this matrix exactly so your custom player matches the rendered MP4 export and the Hyperhuman first-party players byte-for-byte.

| `videoAudioMode` | Voice instructions | Beeps / countdowns | Background music* |
|-------------------|---------------------|--------------------|-------------------|
| `full` (default) | yes | yes | yes |
| `instructionsOnly` | yes | no | yes |
| `beepsOnly` | no | yes | yes |
| `none` | no | no | no |

\* Background music is additionally gated by the workout's `mixBgMusic` flag - if `mixBgMusic` is `false`, music never plays regardless of `videoAudioMode`.

```javascript
// Reference snippet - matches backend export pipeline 1:1.
const shouldIncludeVoiceInstructions = (mode) =>
  mode === undefined || mode === 'full' || mode === 'instructionsOnly';

const shouldIncludeBeeps = (mode) =>
  mode === undefined || mode === 'full' || mode === 'beepsOnly';

const shouldIncludeBackgroundMusic = (mode, mixBgMusic) =>
  mode !== 'none' && (mixBgMusic ?? true);
```

`undefined` is treated as `full` so older clients keep working when the field is added or omitted server-side.

---

## 3. PlaylistManager

A normalized segment view simplifies the rest of the player. `GET /v1/workouts/{workoutId}/playlist` returns `{ data, presentationStyle?, links? }` where `data` is an **array of single-key objects** (not a flat `instances` array). Each item looks like `{ "exercise": { ... } }` or `{ "break": { ... } }` / `{ "intro": ... }` / `{ "outro": ... }` / `{ "class": ... }`.

Normalize before playback:

```javascript
function normalizePlaylist(playlistResponse) {
  return (playlistResponse.data || []).map((item) => {
    const kind = Object.keys(item)[0];
    return { kind, ...item[kind] };
  });
}
```

Treat each normalized segment as one of:

- `exercise` - has `exerciseId`, nested `intro` / `main` media, `duration` (seconds — the **planned work window**, not the MP4 length), optional `reps`.
- `multi-exercise` - circuit / original-audio block; `duration` equals the source file.
- `class` - instructor-led session; intro + main video; `duration` equals the source file. When `chapters` is present, use it for in-video exercise navigation (see below).
- `break` - rest; usually `duration` only.
- `intro` / `outro` (kinds `workout-intro` / `workout-outro`) - bookend segments.

> Do not confuse this with **AI generate** / JSON-receiver payloads, which use a flat `instances[]` with `kind: "single-exercise" | "rest"` and `durationSeconds`. Those are a different contract (embedded JSON player), not the `/playlist` response.

```javascript
class PlaylistManager {
  constructor(playlistResponse) {
    this.segments = normalizePlaylist(playlistResponse);
    this.index = 0;
  }
  current() { return this.segments[this.index]; }
  next() { return this.segments[++this.index]; }
  previous() { if (this.index > 0) return this.segments[--this.index]; }
  isLast() { return this.index === this.segments.length - 1; }
  jumpTo(segmentId) {
    const target = this.segments.findIndex((s) => s.id === segmentId);
    if (target >= 0) this.index = target;
    return this.current();
  }
}
```

### Class chapter navigation

When a playlist item has a `class` key and `class.chapters` is a non-empty array, each chapter marks an exercise block within `class.main.video`:

| Field | Use |
|-------|-----|
| `startTimeSec` / `endTimeSec` | Seek bounds within the class video (seconds) |
| `name` | Label for the active chapter in your UI |
| `equipment` / `muscleGroups` | Optional context (stored at analysis time; not re-localized by `?locale=`) |

When `chapters` is **omitted**, render the class as a single timer block (legacy behavior).

```javascript
function activeChapterIndex(chapters, currentTimeSec) {
  if (!chapters?.length) return -1;
  return chapters.findIndex(
    (ch) => currentTimeSec >= ch.startTimeSec && currentTimeSec < ch.endTimeSec,
  );
}

function jumpToChapter(videoEl, chapters, index) {
  if (!chapters?.[index]) return;
  videoEl.currentTime = chapters[index].startTimeSec;
}
```

Attach a `timeupdate` listener on the class `main.video` element to drive chapter-aware progress UI. Full-workout **exports** do not include chapter markers — use the playlist API for interactive class navigation.

---

## 4. MediaController

Three independent audio channels plus one video channel:

- **Video** - Progressive MP4 via `<video>` or platform-native player (AVPlayer / ExoPlayer). No HLS packager / `hls.js` required. For `exercise` segments, honor `main.video.loop` (`-1` = keep looping). The file is often shorter than `duration` — that is the work-window clock, not the file length. Auto-advance on `segmentElapsed >= duration`, not on video `ended`.
- **Voice instructions** - load `voiceInstructionsUrl` into a separate `<audio>` element. Start at segment start; stop on segment end / pause.
- **Beeps** - load `beepsTrackUrl` into a separate `<audio>` element. Same lifecycle as voice.
- **Background music** - looping playlist driven by the `/music` response. Independent of segment boundaries; ducks when voice plays.

Channel mixing rules:

1. Apply the audio policy matrix above before any channel is started.
2. Voice and beeps are short cues; align them to segment-start time, not video buffer events.
3. Background music continues across segments. Stop on workout `pause`, `restart`, or `done`, and when the user turns music off.
4. On segment change, fade music down ~6 dB while the voice cue plays, then restore.
5. **Music-off must pause BGM**, not only lower volume. On iOS (all browsers / WebKit), `HTMLMediaElement.volume` is ignored — setting `volume = 0` leaves audio playing while your UI can show a muted icon. Use `audio.pause()` and `audio.muted = true` for music-off; on music-on, unmute and `play()` only if the workout is running. Keep voice/beeps on separate elements so music-off does not silence cues.
6. When `mixBgMusic === false` (or `videoAudioMode === 'none'`), do not fetch `/music` and do not show music prev/next/on-off controls.

### Browser audio policy

Modern browsers will not autoplay audible media without a user gesture. To stay compliant:

- Tie the first `play()` call to the same click/tap that calls `sessions/start`.
- If you must autoplay (kiosk/embed), set the video and music elements to `muted` and document the limitation.
- Treat `play()` as a promise; show a fallback play button if it rejects.
- Never use `volume = 0` alone as a mute on iOS — pause + `muted` (see mixing rule 5).

```javascript
try { await videoEl.play(); }
catch (_) { showStartOverlay(); }
```

---

## 5. ProgressTracker

Two timelines run in parallel:

- **Segment timeline** - 0 to `currentSegment.duration` (playlist field; seconds). Drives the on-screen countdown and beep cues. For `exercise` this is the planned work window, **not** `video.duration` from the file.
- **Workout timeline** - 0 to `workout.totalDurationSeconds`. Drives the overall progress bar.

Implement as a single `requestAnimationFrame` loop in the browser, or a 250 ms interval for native. Avoid relying on `<video>.currentTime` for segment progress when the audio policy strips the video; keep an independent monotonic clock. Loop the `<video>` (`loop` attribute or replay on `ended`) while `segmentElapsed < duration`.

```javascript
class ProgressTracker {
  constructor(totalDurationSeconds) {
    this.totalDurationSeconds = totalDurationSeconds;
    this.workoutElapsed = 0;
    this.segmentElapsed = 0;
    this.lastTick = performance.now();
  }
  tick() {
    const now = performance.now();
    const dt = (now - this.lastTick) / 1000;
    this.lastTick = now;
    this.workoutElapsed += dt;
    this.segmentElapsed += dt;
  }
  resetSegment() { this.segmentElapsed = 0; }
}
```

---

## 6. NavigationHandler

User intents you must support:

| Intent | Action |
|--------|--------|
| `play` | Resume current segment; resume music. |
| `pause` | Pause video, voice, beeps, music. Do not advance segment. |
| `next` | Advance to next segment; reset segment progress; restart media. |
| `previous` | Go back one segment; reset segment progress. |
| `restart` | Re-start current segment from 0. |
| `done` | Stop all media; send a final `progress()` then `complete()` (force 100%); mark complete in UI. |
| `auto-advance` | Triggered by `segmentElapsed >= currentSegment.durationSeconds`. Equivalent to `next`, except `done` is invoked when `isLast()` is true. |

If the player is embedded, accept the same actions over `postMessage` (see the **Player Cast Controls** section in `overview.md`). Note the embed cast channel matches action strings literally and uses `prev` (not `previous`); it also accepts `done_exercise` to mark the current rep-based exercise complete.

---

## 7. SessionReporter

The session-tracking endpoints let Hyperhuman attribute completions, drive insights, and feed analytics. Wire them up even if you don't display analytics yourself.

```javascript
class SessionReporter {
  constructor(workoutId, baseUrl, apiKey) {
    this.workoutId = workoutId;
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
    this.sessionId = null;
  }
  async start() {
    const res = await fetch(`${this.baseUrl}/v1/workouts/${this.workoutId}/sessions/start`, {
      method: 'POST',
      headers: { 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
    });
    const body = await res.json();
    // Success responses are wrapped in a data envelope: { data: { id, status, ... } }.
    this.sessionId = body.data.id;
  }
  // progressSeconds = actual playback time in seconds (excludes paused time).
  // workoutSegmentId is optional and capped at 24 chars server-side.
  async progress(progressSeconds, workoutSegmentId) {
    if (!this.sessionId) return;
    await fetch(`${this.baseUrl}/v1/workouts/${this.workoutId}/sessions/${this.sessionId}`, {
      method: 'PATCH',
      headers: { 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({ progressSeconds, workoutSegmentId }),
    });
  }
  // Abandon: close the session keeping the last PATCHed progress. Body is { sessionId } only.
  async end() {
    if (!this.sessionId) return;
    await fetch(`${this.baseUrl}/v1/workouts/${this.workoutId}/sessions/end`, {
      method: 'POST',
      headers: { 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId: this.sessionId }),
    });
  }
  // Natural finish: force 100% completion. Send a final progress() first so the
  // audit trail keeps the real playback duration before complete overwrites it.
  async complete() {
    if (!this.sessionId) return;
    await fetch(`${this.baseUrl}/v1/workouts/${this.workoutId}/sessions/complete`, {
      method: 'POST',
      headers: { 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId: this.sessionId }),
    });
  }
  // ratingId / difficultyId are option ids from GET /v1/workouts/feedback/options
  // (NOT raw numbers). comment is free text. The session id is workoutSessionId.
  async feedback({ ratingId, difficultyId, comment }) {
    if (!this.sessionId) return;
    await fetch(`${this.baseUrl}/v1/workouts/${this.workoutId}/feedback`, {
      method: 'POST',
      headers: { 'X-Api-Key': this.apiKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({ workoutSessionId: this.sessionId, ratingId, difficultyId, comment }),
    });
  }
}
```

Fetch the rating/difficulty scales once and submit the chosen option **ids**:

```javascript
// GET /v1/workouts/feedback/options -> { data: { rating: [{ id, label, value }], difficulty: [...] } }
const options = await fetch(`${baseUrl}/v1/workouts/feedback/options`, { headers }).then((r) => r.json());
await reporter.feedback({
  ratingId: options.data.rating[4].id,
  difficultyId: options.data.difficulty[2].id,
  comment: 'Great session',
});
```

Recommended cadence:

- `start` once at boot.
- `progress` every 15-30 seconds AND on segment change (send `progressSeconds`).
- `complete` once on natural finish (the workout timer reaches the end / "mark as done") - forces 100%.
- `end` on abandon: user-confirmed exit mid-workout and on `pagehide` (use `navigator.sendBeacon` for the last case). Keeps the last PATCHed progress instead of forcing 100%.
- `feedback` from a post-workout sheet; optional, but it is the only loop that improves AI Recommend, Generate, and Adapt for that user. Submit option **ids** from `GET /v1/workouts/feedback/options` (`ratingId` / `difficultyId`), not raw numbers.

### Attributing sessions to an end user (no member token)

By default an API-key-only session is **anonymous** (`traineeId: null`). To attribute the session, feedback, and analytics to a specific end user **without** a member `Authorization: Bearer` token, add the end-user fields to **every** lifecycle call (`start`, `PATCH`, `end`, `complete`, `feedback`):

- `endUserId` - internal Hyperhuman user id or email, OR `externalUserId` - the external UUID stored on the team membership.
- `organizationId` - required for standard/club keys that span organizations; defaults to the API key team for team-scoped keys.

Send them in the **body** for `POST`/`PATCH` and as **query params** for `GET`. The backend resolves the identifier against the API key's team; a member Bearer token, when present, always wins and these fields are ignored. See the attribution rules in [AGENTS.md](https://content.api.hyperhuman.cc/AGENTS.md) section 9. **HyperCast** `POST /v1/cast/pairing/{sessionId}/workout/start` uses the same field names but **does not** let a Bearer token win — only the API key and those fields attribute the cast session; a non-member identifier returns `404` and playback does not start.

### Resume an in-progress session

`GET /v1/workouts/{workoutId}/sessions/recent` returns the end user's most recent **in-progress** (not completed) session, so you can offer "resume" instead of starting fresh. It needs an identity: a member Bearer token, or the `endUserId` / `externalUserId` query fields above. A fully anonymous key-only call has no identity to resolve and returns `404`.

---

## 8. Branding

Branding comes from two different sources - keep them separate:

- **`presentationStyle`** is on the workout itself (returned on the `/playlist` response). It controls which overlays the player draws (values below).
- **Org branding** (logo, watermark, brand colors) is on the *organization*, not the workout. Fetch it once from `GET /v1/orgs/{organizationId}/metadata`: `logoUrl`, `watermarkUrl`, `brandMainColor`, `brandSecondaryColor`, `brandTextColor`. Any of these may be `null` when the org has not configured them.

### Owner workout defaults (live pools)

A third layer is the **workout owner's** Workout Defaults (Teams: Settings → Branding → Default workout style). These are **per user**, resolved from `workout._ownerId` / trainer — not org-wide.

On every `GET /playlist` the API samples current pools:

- `break.image.url` — owner's Visuals **break** images (random pick; empty → Hyperhuman stock)
- outro `image.url` — owner's Visuals **completion** images
- exercise `intro.audio` — owner's **between-exercise cues** when the exercise has no own `exerciseCue`

`GET /v1/workouts/{id}/music` is the owner's **current** PulseMix pool (category-filtered). Never-configured music → stock. Explicitly cleared after category edits → `{ tracks: [] }`.

**Stamped on the workout (not live from Defaults):** `presentationStyle`, `skipWorkoutPreview`, `skipWorkoutCompletionScreen`, `mixBgMusic`, `videoAudioMode`, and saved intro/outro audio arrays. A Format change in Teams does not rewrite those fields on existing workouts.

**Baked files vs playlist:** a previously rendered MP4 / audio export does **not** pick up new images or music. Re-export. Custom players that always call `/playlist` and `/music` see the new stills and tracks immediately.

JSON / AI-generate embeds have no persisted workout id — no `/music`, no default cues (silent preview). See the scope note at the top of this guide.

Two rules:

1. Read `presentationStyle` from the workout's `/playlist` response. There is no org-level `presentationStyle`, so when the workout omits it, default to `full` (all overlays).
2. Color values are hex strings. If you forward them via URL query (e.g. to a sub-iframe), URL-encode the `#` (`%23FF6600`).

`presentationStyle` values:

- `full` - all overlays (timer, next-exercise, branding).
- `minimal` - timer + branding only.
- `essential` - timer only.
- `noOverlays` - bare video; no timer, no overlays. Useful for casting and PiP modes.

---

## 9. Localization

Pass `locale` (BCP-47, e.g. `en-US`, `fr-FR`) to `/playlist`. Voice instructions and on-screen text follow the locale; beeps and music do not. If the requested locale isn't rendered, the playlist falls back to English so playback never breaks.

Refresh the playlist when the user switches locales mid-session - segment ids are stable across locales, so you can preserve the current `index` and call `jumpTo(segmentId)` after re-fetching.

---

## 10. End-to-end skeleton

Putting the pieces together:

```javascript
async function bootCustomPlayer({ baseUrl, apiKey, workoutId, locale = 'en-US' }) {
  const headers = { 'X-Api-Key': apiKey };

  const [workoutEnvelope, playlist] = await Promise.all([
    fetch(`${baseUrl}/v1/workouts/${workoutId}`, { headers }).then((r) => r.json()),
    fetch(`${baseUrl}/v1/workouts/${workoutId}/playlist?locale=${locale}`, { headers }).then((r) => r.json()),
  ]);
  // Workout detail is wrapped: { data: { id, videoAudioMode, mixBgMusic, duration, ... } }
  const workout = workoutEnvelope.data ?? workoutEnvelope;

  const policy = {
    voice: shouldIncludeVoiceInstructions(workout.videoAudioMode),
    beeps: shouldIncludeBeeps(workout.videoAudioMode),
    music: shouldIncludeBackgroundMusic(workout.videoAudioMode, workout.mixBgMusic),
  };

  // /music returns { tracks: [...], totalDuration, workoutDuration }; empty pool is { tracks: [] }.
  const music = policy.music
    ? await fetch(`${baseUrl}/v1/workouts/${workoutId}/music`, { headers }).then((r) => r.json())
    : { tracks: [] };

  const reporter = new SessionReporter(workoutId, baseUrl, apiKey);
  await reporter.start();

  const playlistMgr = new PlaylistManager(playlist);
  const tracker = new ProgressTracker(workout.duration ?? workout.totalDurationSeconds);
  const media = new MediaController({ workout, policy, music });

  media.load(playlistMgr.current());

  const startButton = document.getElementById('start');
  startButton.addEventListener('click', async () => {
    await media.play();
    requestAnimationFrame(function loop() {
      tracker.tick();
      if (tracker.segmentElapsed >= (playlistMgr.current().duration ?? playlistMgr.current().durationSeconds)) {
        if (playlistMgr.isLast()) {
          media.stop();
          reporter.complete(); // natural finish -> force 100%
          return;
        }
        const next = playlistMgr.next();
        tracker.resetSegment();
        media.load(next);
        media.play();
        reporter.progress(Math.floor(tracker.workoutElapsed), next.id);
      }
      requestAnimationFrame(loop);
    });
  });

  window.addEventListener('pagehide', () => {
    // Abandon flush: end keeps the last PATCHed progress. Body is { sessionId } only.
    navigator.sendBeacon(
      `${baseUrl}/v1/workouts/${workoutId}/sessions/end`,
      new Blob([JSON.stringify({ sessionId: reporter.sessionId })], {
        type: 'application/json',
      }),
    );
  });
}
```

This is intentionally framework-agnostic. Wrap the same logic in React hooks, SwiftUI view models, or Kotlin coroutines as needed.

---

## 11. Things that will trip you up

- **Audio policy off-by-one.** Treat `undefined` as `full`. If the field is missing from your response, default to all channels on - this matches the export pipeline.
- **Music URL expiration.** Pre-signed S3 URLs expire after ~7 days. Re-fetch `/music` on long-lived embeds, or persist the playlist for less than 7 days.
- **Video / playlist URL expiration.** Segment `video.url` and instruction audio URLs from `/playlist` (and `GET /video-asset/{id}`) are S3 presigned links (~7 days). Re-fetch the playlist (or video-asset) before expiry; do not hardcode or treat URLs as durable asset ids. Not HLS/DASH/ABR.
- **Poster vs video URL.** `exercise.main.video.poster` (and `intro.video.poster` on the same segment) is a **public unsigned S3 JPEG** — load it as `<img>` or `<video poster>`. It is not signed and does not need the API key. Do **not** use `video.url` (the MP4) as the thumbnail. Workout **intro** and **outro** `video` objects have **no** `poster`/`thumbnail`; breaks use `image.url`. Binding `src=""` / a missing poster makes the browser fetch the current HTML page. Workout-detail `preview.poster` is a different field (cover/still/clip) than segment posters.
- **OpenAPI examples are not live assets.** Do not fetch example hosts from Swagger (`example.com/...` returns HTML; invented CDN hosts are not the image pipeline). Use the `poster` string from a real playlist response.
- **Locale fallback.** `/playlist` falls back to English silently. The `/export/.../stream_url` endpoints do NOT - they return `404` if the requested locale hasn't been rendered. Custom players should always use `/playlist`, not `/export/...`.
- **Break / completion images are live.** Do not cache `break.image.url` or outro `image.url` as if they were saved on the workout. They are sampled from the owner's current Visuals on each playlist fetch. Same for `/music`. Format / `videoAudioMode` / saved bookend audio are on the workout document.
- **Segment ids are not array indices.** Use `id` for jumps and reporting; use indices only for adjacency math.
- **Browser autoplay.** First `play()` must be inside a user gesture. If your UX requires autoplay (e.g. carousel), `muted=true` is the only reliable workaround.
- **iOS BGM “mute” that keeps playing.** `HTMLMediaElement.volume` is a no-op on iOS. Music-off and workout-pause must call `pause()` on the music element (and prefer `muted = true`). Match the hosted player: music-off affects BGM only.
- **CORS.** All Content API endpoints accept browser-origin requests. Pre-signed S3 **video/audio** URLs use S3's CORS — they work for `<video>` and `<audio>` tags but not `fetch()` against arbitrary headers. Playlist **poster JPEGs** are public-read objects; `<img>` / `<video poster>` do not need the API key.
- **Rate limit weight.** Recommend, generate, adapt, insights, chat writes, video generation, full video export, nutrition photo-log analysis, and `GET .../stock-exercises` count as **10x**. Pure playback (`GET /workouts`, `/playlist`, `/music`, `GET /video-asset/{id}`) is **1x**. The **hosted** Embedded Player uses those same 1x endpoints (plus session lifecycle on a persisted id) with the key from the iframe URL or `HYPERHUMAN_EMBED_INIT` — there is no separate “embed play” unit. Trialing keys use 1/100 of paid caps. On 429 honor `Retry-After` then `X-RateLimit-Reset`. Stock catalog is not a playback entitlement on Platform/trial/Ultra monthly; that plan-gated 403 does not consume quota.

---

## See also

- [Overview](https://content.api.hyperhuman.cc/) - quick start, Embedded Player, and **Branded library embed** (member `/workouts` and `/plans` grids)
- [Scalar / Swagger](https://content.api.hyperhuman.cc/docs) - rendered overview with the same sections
- [LLM bundle](https://content.api.hyperhuman.cc/llms-full.txt) - per-endpoint reference for code-gen
- [Agent integration guide](https://content.api.hyperhuman.cc/AGENTS.md) - conventions for coding agents
- [Developer cheat sheet](https://content.api.hyperhuman.cc/docs/guides/content-API-cheat-sheet.md) - copy-paste flows and rate-limit notes
