# 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 HLS streams, voice instructions, beep cues, and timing. Drives the player. |
| `GET /v1/workouts/{workoutId}/music` | Shuffled, deduplicated background music playlist (pre-signed S3 URLs, 7-day expiration). Respects org/trainer music preferences and the workout's `mixBgMusic` flag. |

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 /v1/workouts/{workoutId}/feedback`).

---

## 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. The playlist endpoint returns a list of instances; treat each instance as one of:

- `single-exercise` - has `exercise.id`, `videoUrl` (HLS), optional `voiceInstructionsUrl`, optional `beepsTrackUrl`, `durationSeconds`.
- `rest` - has `durationSeconds` only; usually no media.
- `intro` / `outro` - introductory/concluding segments; treat like `single-exercise` for media handling.

```javascript
class PlaylistManager {
  constructor(playlist) {
    this.segments = playlist.instances;
    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();
  }
}
```

---

## 4. MediaController

Three independent audio channels plus one video channel:

- **Video** - HLS via `<video>`, `hls.js`, or platform-native player (AVPlayer / ExoPlayer).
- **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. Only stop on `pause`, `restart`, or `done`.
4. On segment change, fade music down ~6 dB while the voice cue plays, then restore.

### 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.

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

---

## 5. ProgressTracker

Two timelines run in parallel:

- **Segment timeline** - 0 to `currentSegment.durationSeconds`. Drives the on-screen countdown and beep cues.
- **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.

```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; mark complete; flush final session update. |
| `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](#player-cast-controls) section in `overview.md`).

---

## 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();
    this.sessionId = body.id;
  }
  async progress(elapsedSeconds, currentSegmentId) {
    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({ elapsedSeconds, currentSegmentId }),
    });
  }
  async end(completed) {
    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, completed }),
    });
  }
  async feedback({ rating, difficulty, notes }) {
    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({ sessionId: this.sessionId, rating, difficulty, notes }),
    });
  }
}
```

Recommended cadence:

- `start` once at boot.
- `progress` every 15-30 seconds AND on segment change.
- `end` on `done`, on user-confirmed exit, and on `pagehide` (use `navigator.sendBeacon` for the last case).
- `feedback` from a post-workout sheet; optional, but it is the only loop that improves AI Recommend, Generate, and Adapt for that user.

---

## 8. Branding

Every workout document carries `presentationStyle` and the org's branding (`brandLogoUrl`, `brandWatermarkUrl`, `brandMainColor`, `brandSecondaryColor`, `brandTextColor`). Two rules:

1. Read the workout's `presentationStyle` first; fall back to org defaults from `GET /v1/orgs/{organizationId}/metadata` only if the workout omits it.
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 [workout, 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()),
  ]);

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

  const music = policy.music
    ? await fetch(`${baseUrl}/v1/workouts/${workoutId}/music`, { headers }).then((r) => r.json())
    : { items: [] };

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

  const playlistMgr = new PlaylistManager(playlist);
  const tracker = new ProgressTracker(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().durationSeconds) {
        if (playlistMgr.isLast()) {
          media.stop();
          reporter.end(true);
          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', () => {
    navigator.sendBeacon(
      `${baseUrl}/v1/workouts/${workoutId}/sessions/end`,
      new Blob([JSON.stringify({ sessionId: reporter.sessionId, completed: false })], {
        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.
- **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/...`.
- **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.
- **CORS.** All Content API endpoints accept browser-origin requests. Pre-signed S3 URLs use S3's CORS - they work for `<video>` and `<audio>` tags but not `fetch()` against arbitrary headers.
- **Rate limit weight.** AI and full-video endpoints (recommend, generate, adapt, insights, full video export) count as 10x. Pure playback (`GET /workouts`, `/playlist`, `/music`) is 1x.

---

## 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
