# Control the player

> Play, pause, seek and track progress from the page that embeds a Wolvy video: the wolvy-player.js script, every method and event, and course completion.

Source: https://docs.wolvy.net/player-api/ · Wolvy Developer API v1 · updated 2026-11-12

_Guide · needs your code_

The Player API lets the page that embeds a Wolvy video drive it — play, pause, seek, speed, quality, captions — and hear what happens: time updates, the end of the video, and how much of it a viewer has really watched.

Script `embed.wolvy.stream/sdk/wolvy-player.js` · Off by default · per account · **13 events** · control only

## What the Player API is

The Wolvy player lives in an iframe, which normally keeps your page and the player apart. With the Player API switched on, the two can talk: your page sends commands, and the player reports events. It is what you need to build:

- **Your own buttons** — a “Play lesson” button, a “Jump to chapter 3” link, keyboard shortcuts.
- **Resume where they stopped** — remember the position and go back to it.
- **Course completion** — mark a lesson done when a student has watched 90% of it.
- **Your own analytics** — play, pause and end events, next to the rest of your page’s events.

Nothing changes for viewers: it is the same player, with the same controls, protection and watermark.

> **Security — Control only**
> The API can steer playback and report playback. It can never hand your page — or anyone else’s — a licence, a token, a video URL, a key or the watermark’s viewer id. See [what it cannot do](https://docs.wolvy.net/player-api/#security).

## Turn it on

1. **Switch it on for the account**In the dashboard, open Settings → Player and turn on Player API. It is off by default, and it applies to every video on the account.
2. **Embed as usual**Use the normal [iframe](https://docs.wolvy.net/embedding/#iframe). Nothing is added to the embed URL.
3. **Load the script on your page**`https://embed.wolvy.stream/sdk/wolvy-player.js` defines `WolvyPlayer`. It is about 8 KB and has no dependencies.

The player only listens to **the page that embeds it** — the same page the [domain rules](https://docs.wolvy.net/embedding/#domains) checked. Another site, another frame on your page, or a browser extension’s frame gets no answer.

## Quick start

```html
<iframe
  id="lesson"
  src="https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=?vt=…"
  allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
  allowfullscreen
  referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<button id="play">Play</button>

<script src="https://embed.wolvy.stream/sdk/wolvy-player.js"></script>
<script>
  const player = new WolvyPlayer('#lesson');

  player.on('ready', ({ duration }) => {
    console.log('Player ready, the video is', duration, 'seconds long');
  });

  // play() must come from a click unless the player is muted
  document.querySelector('#play').addEventListener('click', () => {
    player.play().catch((error) => console.warn(error.code));
  });
</script>
```

_Also on the page in React._

- `new WolvyPlayer(iframe)` takes the iframe element or a CSS selector. Create it any time: before or after the iframe has loaded.
- Every method returns a **promise**. Calls made before the player is ready wait for it.
- `player.ready()` resolves once the player can take commands, with its starting state.
- `player.on(event, fn)`, `player.off(event, fn)` and `player.once(event, fn)` subscribe to [events](https://docs.wolvy.net/player-api/#events).
- `player.destroy()` stops listening. Call it when you remove the iframe.

## Methods

### Control

| Method | Resolves with | Notes |
| --- | --- | --- |
| `play()` | true | Rejects with `play_blocked` when the browser wants a click first (see [Limits](https://docs.wolvy.net/player-api/#limits)). |
| `pause()` | true |   |
| `seek(seconds)` | the position it moved to | A number, 0 or more. Past the end is moved to the end. `not_ready` before the video has loaded. |
| `mute() · unmute()` | true |   |
| `setVolume(volume)` | the volume | A number from 0 to 1. It does not unmute; call `unmute()` for that. |
| `setPlaybackRate(rate)` | the rate | One of 0.5, 0.75, 1, 1.25, 1.5, 2 — the speeds in the player’s own menu. |
| `setQuality(height)` | the height, or "auto" | A height from `getQualities()` as a number, or `"auto"`. `not_supported` on Safari and iPhone, where the system player picks the quality. |
| `setCaptions(language)` | the language, or null | A language code from `getCaptions()`, or `"off"`. |

### Read

| Method | Resolves with |
| --- | --- |
| `getCurrentTime()` | `seconds` |
| `getDuration()` | `seconds (0 until the video has loaded)` |
| `getPaused()` | `true or false` |
| `getMuted()` | `true or false` |
| `getVolume()` | `0 to 1` |
| `getPlaybackRate()` | `e.g. 1.5` |
| `getQualities()` | `{ available: [1080, 720, …], selected: 720 \| "auto", playing: 720 \| null }` |
| `getCaptions()` | `{ available: [{ language, label }], active: "ar" \| null }` |
| `getProgress()` | `{ percent, seconds, duration }` |

_Volume, speed, quality, captions_

```js
const player = new WolvyPlayer('#lesson');

await player.ready();

await player.setVolume(0.5);          // 0 to 1; does not unmute
await player.setPlaybackRate(1.5);    // 0.5, 0.75, 1, 1.25, 1.5 or 2

const { available } = await player.getQualities();   // e.g. [1080, 720, 480, 360]
if (available.includes(720)) await player.setQuality(720);
await player.setQuality('auto');

const captions = await player.getCaptions();         // { available: [{ language, label }], active }
if (captions.available.some((t) => t.language === 'ar')) await player.setCaptions('ar');
await player.setCaptions('off');
```

Each control method does exactly what the matching button in the player does, so the player’s own menus stay in step with your page.

## Events

| Event | Data | When |
| --- | --- | --- |
| `ready` | `{ currentTime, duration, paused, muted, volume, playbackRate }` | The player accepts commands from now on. Sent once. |
| `play` | `{ currentTime }` |   |
| `pause` | `{ currentTime }` |   |
| `ended` | `{}` |   |
| `seeked` | `{ currentTime }` | After a jump, whoever asked for it. |
| `timeupdate` | `{ currentTime, duration }` | At most 4 times a second while playing. |
| `progress` | `{ percent, seconds, duration }` | How much of the video has been played. See [Course completion](https://docs.wolvy.net/player-api/#progress). |
| `volumechange` | `{ volume, muted }` |   |
| `ratechange` | `{ playbackRate }` | Buffering pauses are not reported as a speed change. |
| `qualitychange` | `{ available, selected, playing }` | The viewer or your page picked a quality, or the playing height changed. |
| `captionschange` | `{ active }` | A language code, or null when captions are off. |
| `fullscreenchange` | `{ fullscreen }` |   |
| `error` | `{ code }` | The player could not play. A code only — see [Errors](https://docs.wolvy.net/player-api/#errors). |

Events fire whoever caused them: your page, the viewer using the player’s controls, or the player itself.

_Resume where the viewer stopped_

```js
const KEY = 'lesson-42-position';
const player = new WolvyPlayer('#lesson');

// Remember where the viewer is (timeupdate arrives at most 4 times a second).
player.on('timeupdate', ({ currentTime }) => {
  localStorage.setItem(KEY, String(Math.floor(currentTime)));
});

// Go back there next time.
player.ready().then(() => {
  const saved = Number(localStorage.getItem(KEY));
  if (saved > 5) player.seek(saved);
});
```

## Course completion

The `progress` event answers “how much of this video has this viewer actually watched?”

- **`percent` counts seconds that were played, each one once.** Skipping to the end does not reach 100%, and replaying the first minute ten times counts as one minute.
- **`seconds`** is that count; **`duration`** is the video’s length.
- It is sent as the number changes, at most once a second, and once more when the video ends.
- It starts from zero on every page load. To add up a lesson across visits, store it on your side.

_Mark a lesson complete at 90%_

```js
const player = new WolvyPlayer('#lesson');
let reported = false;

player.on('progress', async ({ percent }) => {
  if (reported || percent < 90) return;
  reported = true;

  // Your own endpoint. Decide completion on your server, for the signed-in student.
  await fetch('/lessons/42/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ percent }),
  });
});
```

> **Careful — A signal, not proof**
> Like anything a browser reports, a determined student can fake it. Use it for progress bars and course completion. For figures you must be able to stand behind, read watch time per viewer from [viewer sessions](https://docs.wolvy.net/api/#list-sessions), which Wolvy measures on its own servers.

## Errors

A rejected promise carries an `Error` with a `code`. The `error` event carries the same kind of code, and nothing else.

| Code | Where | What it means |
| --- | --- | --- |
| `not_available` | ready() and every method | The player never answered: the Player API is off for the account, the iframe did not load a Wolvy player (a 403 page, for example), or it is still loading after 15 seconds. |
| `play_blocked` | play() | The browser refused to start playback without a click. Mute first, or call play() from a click handler. |
| `invalid_argument` | any setter | The value is the wrong type or not on offer: a string where a number is expected, a speed that is not in the list, a quality or caption language the video does not have. |
| `not_supported` | setQuality() | This device’s player chooses the quality itself (Safari on Mac, every browser on iPhone and iPad). |
| `not_ready` | seek() | The video’s length is not known yet. Wait for ready. |
| `timeout` | any method | The player answered ready earlier but not this call, usually because the iframe was reloaded or removed. |
| `network_error` | the error event, then every control method | The video could not be loaded. |
| `playback_error` | the error event, then every control method | The video could not be played on this device. |
| `device_not_supported` | the error event | App-Only mode is on and this browser has no hardware DRM; the player is showing how to switch to a supported browser or the app. |

_Handle a refusal_

```js
try {
  await player.play();
} catch (error) {
  if (error.code === 'play_blocked') {
    // The browser wants a click first. Either wait for one, or:
    await player.mute();
    await player.play();
  } else {
    console.warn('Player refused:', error.code);
  }
}

// Something went wrong inside the player (it is already showing its own message).
player.on('error', ({ code }) => showFallback(code));
```

After an `error` event the read methods still answer, and the control methods reject with that error’s code.

## Without the script

The script is optional. Underneath, your page and the player exchange plain `postMessage` objects:

```text
you → player { type: "wolvy:player", v: 1, id, method, value? }
player → you { type: "wolvy:player", v: 1, id, ok: true, value } // or ok: false, error: "code"
player → you { type: "wolvy:player", v: 1, event, data }
```

- `id` is yours: a number, or a string of up to 64 characters. The answer repeats it.
- `method` is one of the names above, without brackets: `"seek"`, `"getDuration"`. Its argument goes in `value`.
- A message the player does not recognise gets **no answer**. So does everything while the Player API is off.
- Send `{ method: "ping" }` to ask a player that may already be running whether it is ready; it answers with its state.

_Raw postMessage_

```js
const frame = document.querySelector('#lesson');
const PLAYER = 'https://embed.wolvy.stream';

// 1. Listen. Check BOTH the origin and the window the message came from.
window.addEventListener('message', (event) => {
  if (event.origin !== PLAYER || event.source !== frame.contentWindow) return;
  const message = event.data;
  if (!message || message.type !== 'wolvy:player') return;

  if (message.event) console.log('event', message.event, message.data);
  else console.log('answer to', message.id, message.ok ? message.value : message.error);
});

// 2. Ask. Always name the player's origin as the target, never "*".
frame.contentWindow.postMessage(
  { type: 'wolvy:player', v: 1, id: 1, method: 'seek', value: 120 },
  PLAYER,
);
```

## What it cannot do

- **It never exposes protected data.** No method returns, and no event carries, a DRM licence, a CDN token, a session id, the viewer id, the watermark text, a manifest or segment URL, or a key.
- **It only talks to the embedding page.** The player answers the one origin that embedded it, and sends its messages to that origin only. Your own listener should check `event.origin` and `event.source` the same way; the script does.
- **It cannot remove the protection.** The watermark, the domain rules, App-Only mode and DRM are not reachable from the API.
- **It is opt-in.** Off by default for every account, and silent while off.

> **Note — If any site may embed your videos**
> With no [allowed domains](https://docs.wolvy.net/embedding/#domains) set, any site can embed your player, and so control the copy it embeds. That gives it nothing it could not already show; add an allowlist if you want only your own pages to embed at all.

## Limits

| Limit | Why | What to do |
| --- | --- | --- |
| `play()` needs a click, unless muted | Every browser blocks sound that the viewer did not ask for | Call it from a click handler, or `mute()` first |
| No quality list on Safari, iPhone and iPad | Apple’s own player picks the quality | Hide your quality control when `getQualities().available` is empty |
| `setVolume()` has no effect on iPhone and iPad | iOS leaves volume to the hardware buttons | `mute()` and `unmute()` still work |
| No fullscreen method | Browsers only allow fullscreen from a click inside the player | Viewers use the player’s fullscreen button; listen to `fullscreenchange` |
| One player per `WolvyPlayer` | Each iframe is its own player | Create one object per iframe |

## Common questions

### What is the Wolvy Player API?

A way for the page that embeds a Wolvy video to control the player and listen to it: play, pause, seek, volume, speed, quality, captions, and events such as `timeupdate`, `ended` and `progress`. It works over the browser’s `postMessage`, through a small script, `wolvy-player.js`.

### Is the Player API on by default?

No. It is off for every account until you turn it on in the dashboard. While it is off the player ignores every message and sends nothing, and the script reports `not_available`.

### Can my page read the video URL, the DRM licence or the viewer token through it?

No. The API is control only. The player never sends your page a licence or CDN token, a session or viewer id, a manifest or segment URL, a key, or the watermark text — there is no method that returns them and no event that carries them.

### How do I mark a lesson complete when a student has watched it?

Listen to the `progress` event. Its `percent` counts the seconds of the video that were actually played, each once, so skipping ahead or replaying one part does not inflate it. When it passes your threshold, tell your own server. See [Course completion](https://docs.wolvy.net/player-api/#progress).

### Why does play() fail with play_blocked?

Browsers do not let a page start video with sound until the viewer has clicked something. Call `play()` from a click handler, or call `mute()` first — muted playback is always allowed.

### Do I have to use wolvy-player.js?

No. The script is a thin wrapper around `postMessage`; the message format is documented under [Without the script](https://docs.wolvy.net/player-api/#raw). The script saves you the request ids, the timeouts and the origin checks.

Something wrong or unclear on this page? Email [contact@wolvy.net](mailto:contact@wolvy.net?subject=Docs%3A%20Control%20the%20player) — include the page name.
