# @openplayerjs/player

> UI layer, built-in controls, and extension APIs for [OpenPlayerJS](https://openplayerjs.com)

[![npm](https://img.shields.io/npm/v/@openplayerjs/player?color=blue&logo=npm&label=npm)](https://www.npmjs.com/package/@openplayerjs/player)
[![npm downloads](https://img.shields.io/npm/dm/@openplayerjs/player?logo=npm&label=downloads)](https://www.npmjs.com/package/@openplayerjs/player)
[![License](https://img.shields.io/npm/l/@openplayerjs/player)](../../LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![JSDelivr](https://data.jsdelivr.com/v1/package/npm/@openplayerjs/player/badge)](https://www.jsdelivr.com/package/npm/@openplayerjs/player)

This package ships two APIs that cover the same functionality:

- **ESM** (bundlers — Vite, webpack, esbuild): install with npm and use `import` statements. Start at [Quick start (ESM)](#quick-start-esm--bundlers).
- **UMD** (CDN / plain `<script>` tag): load a `<script>` and use the `OpenPlayerJS` global. Start at [Quick start (UMD)](#quick-start-umd--cdn--plain-script-tag) or jump straight to the [UMD API reference](#umd-api-reference).

The two APIs are **not** interchangeable: ESM exports (`createUI`, `buildControls`, etc.) do not exist in the UMD bundle, and the `OpenPlayerJS` global only exists when the UMD bundle is loaded.

> **v3 note:** The v2 `addElement` / `addControl` API accepted a large configuration object passed to the player constructor. In v3 that API has been redesigned for two reasons:
>
> 1. **Security** — the old API accepted arbitrary HTML strings (`content`, `icon`) that could be used for XSS attacks. The new API works with real DOM elements that you create yourself.
> 2. **Clarity** — separating UI extensions from the player constructor makes it obvious what is "core player" and what is "visual customisation".
>
> See [MIGRATION.v3.md](../MIGRATION.v3.md) for a complete before/after comparison.

---

## Installation

```bash
npm install @openplayerjs/player @openplayerjs/core
```

## Quick start (ESM / bundlers)

```ts
import { Core } from '@openplayerjs/core';
import { createUI, buildControls } from '@openplayerjs/player';
import '@openplayerjs/player/openplayer.css';

const video = document.querySelector<HTMLVideoElement>('#player')!;
const core = new Core(video, { plugins: [] });

const controls = buildControls({
  top: ['progress'],
  'bottom-left': ['play', 'time', 'volume'],
  'bottom-right': ['captions', 'settings', 'fullscreen'],
});

createUI(core, video, controls);
```

---

## Quick start (UMD / CDN / plain `<script>` tag)

When you load OpenPlayerJS from a CDN or a plain `<script>` tag, the global `OpenPlayer` constructor is provided by the player bundle. There is no `import` / `export` syntax — everything lives on `window.OpenPlayer`.

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.css" />

<video id="player" src="https://example.com/video.mp4" controls></video>

<script src="https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.js"></script>
<script>
  var player = new OpenPlayerJS('player', {
    controls: {
      top: ['progress'],
      'bottom-left': ['play', 'time', 'volume'],
      'bottom-right': ['captions', 'settings', 'fullscreen'],
    },
  });

  player.init();
</script>
```

> **UMD note:** You must call `player.init()` explicitly after construction. The constructor only stores configuration — `init()` builds the Core, creates the UI, and wires everything up.

### Accessing the underlying Core from UMD

`player.init()` returns the `Core` instance and also stores it internally. To access Core after initialization:

```js
var core = player.init(); // init() returns Core
// — or —
var core = player.getCore(); // getCore() is always available after init()
```

### Adding a plugin (e.g. HLS) in UMD

Plugin bundles register themselves on `window.OpenPlayerPlugins` before `init()` is called. Load them in order:

```html
<script src="https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@openplayerjs/hls@latest/dist/openplayer-hls.js"></script>
<script>
  var player = new OpenPlayerJS('player', {
    // hls-specific config under the 'hls' key (passed to the HLS plugin factory)
    hls: { maxBufferLength: 30 },
  });
  player.init();
</script>
```

---

## UMD API reference

When loading the UMD bundle there are no `import` statements — every part of the API lives on the `OpenPlayerJS` global. The wrapper class calls `buildControls`, `createUI`, and `extendControls` internally during `init()`, so you never call those directly.

> **ESM / bundler users:** ignore this section and work with `Core`, `createUI`, and `buildControls` directly via the [ESM API](#esm-api).

### Static methods

Available on the constructor itself — no instance needed:

| Method                                      | Signature                                      | Description                                                                                                                                                                                                                |
| ------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OpenPlayerJS.registerControl(id, factory)` | `(id: string, factory: () => Control) => void` | Register a custom control factory by string ID so it can be placed by name in the `controls` layout config. Must be called **before** `init()`. See [Registering a control globally → UMD](#umd--registering-before-init). |
| `OpenPlayerJS.setA11yLabel(el, label)`      | `(el: HTMLElement, label: string) => void`     | Attach a visually-hidden accessible label to an element. See [Accessibility](#accessibility).                                                                                                                              |

### Constructor

```js
var player = new OpenPlayerJS(target, config);
```

| Parameter | Type                         | Description                                                                                            |
| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
| `target`  | `string \| HTMLMediaElement` | CSS selector (e.g. `'#player'`), bare element `id` (e.g. `'player'`), or the `HTMLMediaElement` itself |
| `config`  | `object`                     | Optional — layout, labels, and plugin options. See [Configuration](#configuration).                    |

The constructor stores the config but does **not** build the UI. Call `player.init()` immediately after.

### Instance methods

```js
var player = new OpenPlayerJS('player', {
  /* config */
});
var core = player.init(); // build Core, register plugins, create the UI — returns Core
var core = player.getCore(); // access Core at any point after init()
```

| Method                              | Returns          | Description                                                                                                                                        |
| ----------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `player.init()`                     | `Core`           | Build and start the player. Must be called once before any other method.                                                                           |
| `player.getCore()`                  | `Core`           | Return the `Core` instance. Available after `init()`.                                                                                              |
| `player.play()`                     | `Promise<void>`  | Start or resume playback.                                                                                                                          |
| `player.pause()`                    | `void`           | Pause playback.                                                                                                                                    |
| `player.load()`                     | `void`           | Reload the current source.                                                                                                                         |
| `player.destroy()`                  | `void`           | Tear down the player UI and restore the original `<video>` element.                                                                                |
| `player.addCaptions(args)`          | `void`           | Add a text track: `{ src, srclang?, label?, kind?, default? }`.                                                                                    |
| `player.addElement(el, placement?)` | `void`           | Place any `HTMLElement` in the player at `{ v, h }`. Defaults to `{ v: 'bottom', h: 'right' }`.                                                    |
| `player.addControl(control)`        | `void`           | Mount a typed `Control` object. Always appends to the end of its slot — use `registerControl` when ordering relative to built-in controls matters. |
| `player.on(event, cb)`              | `Unsubscribe fn` | Subscribe to a player event. Safe to call before `init()` — listeners are queued and attached when `init()` runs. Returns an unsubscribe function. |
| `player.emit(event, ...args)`       | `void`           | Emit a player event. Requires `init()` to have been called.                                                                                        |

### Instance properties

Get and set these after `player.init()`:

| Property              | Type      | Notes                                     |
| --------------------- | --------- | ----------------------------------------- |
| `player.src`          | `string`  | Get or set the media URL                  |
| `player.currentTime`  | `number`  | Get or set playback position (seconds)    |
| `player.duration`     | `number`  | **Read-only** — total duration in seconds |
| `player.volume`       | `number`  | `0`–`1`                                   |
| `player.muted`        | `boolean` |                                           |
| `player.playbackRate` | `number`  | `1` = normal speed                        |

### Events in UMD

```js
// Works before or after init() — pre-init listeners are queued and attached on init()
var off = player.on('playing', function () {
  console.log('playing');
});
off(); // unsubscribe

// Direct Core event bus access (after init() only)
player.getCore().events.on('ui:controls:show', function () {
  console.log('controls visible');
});

// Emit (after init() only)
player.emit('player:interacted');
```

For the full list of UI events (`ui:controls:show`, `ui:controls:hide`, `ui:menu:open`, etc.) see [Events](#events).

### UMD wrapper → Core equivalence

Every UMD instance method and property is a thin delegate to the underlying `Core` instance. This table shows exactly what each call resolves to, so you can cross-reference with the ESM API or extend `Core` directly after `player.getCore()`.

| UMD (`player.*`)                       | Core equivalent                                                                   | Notes                                                                   |
| -------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `player.init()`                        | `new Core(media, config)` + `buildControls()` + `createUI()` + `extendControls()` | Done internally; returns the `Core` instance                            |
| `player.getCore()`                     | —                                                                                 | Direct reference; no ESM equivalent needed                              |
| `player.play()`                        | `core.play()`                                                                     |                                                                         |
| `player.pause()`                       | `core.pause()`                                                                    |                                                                         |
| `player.load()`                        | `core.load()`                                                                     |                                                                         |
| `player.destroy()`                     | `core.destroy()`                                                                  |                                                                         |
| `player.addCaptions(args)`             | `core.addCaptions(args)`                                                          |                                                                         |
| `player.addElement(el, p?)`            | `core.controls.addElement(el, p?)`                                                | `core.controls` is added by `extendControls`; **not** `player.controls` |
| `player.addControl(control)`           | `core.controls.addControl(control)`                                               | Same note — **not** `player.controls`                                   |
| `player.on(event, cb)`                 | `core.events.on(event, cb)`                                                       | UMD version queues pre-`init()` listeners; Core does not                |
| `player.emit(event, ...args)`          | `core.events.emit(event, ...args)`                                                | After `init()` only in both                                             |
| `player.src`                           | `core.src`                                                                        |                                                                         |
| `player.currentTime`                   | `core.currentTime`                                                                |                                                                         |
| `player.duration`                      | `core.duration`                                                                   |                                                                         |
| `player.volume`                        | `core.volume`                                                                     |                                                                         |
| `player.muted`                         | `core.muted`                                                                      |                                                                         |
| `player.playbackRate`                  | `core.playbackRate`                                                               |                                                                         |
| `OpenPlayerJS.registerControl(id, fn)` | `import { registerControl } from '@openplayerjs/player'`                          | Same global registry — both write to the same module-level `Map`        |
| `OpenPlayerJS.setA11yLabel(el, label)` | `import { setA11yLabel } from '@openplayerjs/player'`                             | Same function, different access path                                    |

---

## Configuration

`@openplayerjs/player` owns UI-specific configuration (labels, sizing, keyboard seek step, and progress-bar interaction flags), but it **augments** the `PlayerConfig` type from `@openplayerjs/core`.

That means you can pass both core and UI options to the same config object — whether you're using `new Core(video, config)` in ESM or `new OpenPlayerJS(target, config)` in UMD:

```ts
import { Core } from '@openplayerjs/core';
import { createUI } from '@openplayerjs/player';

const core = new Core(video, {
  // core
  startTime: 0,
  startVolume: 1,

  // player
  width: 640,
  height: 360,
  step: 5,
  allowSkip: true,
  allowRewind: false,
  labels: {
    play: 'Play',
    pause: 'Pause',
  },
});

createUI(core, video, controls);
```

### UI options

| Option        | Type                     | Default   | Description                                                                      |
| ------------- | ------------------------ | --------- | -------------------------------------------------------------------------------- |
| `width`       | `number \| string`       | —         | Force a specific player width (applied to the wrapper)                           |
| `height`      | `number \| string`       | —         | Force a specific player height (applied to the wrapper)                          |
| `step`        | `number`                 | `0`       | Seek distance in seconds for keyboard shortcuts. `0` means use the default (5 s) |
| `allowSkip`   | `boolean`                | `true`    | Allow seeking forward via the progress bar                                       |
| `allowRewind` | `boolean`                | `true`    | Allow seeking backward via the progress bar                                      |
| `labels`      | `Record<string, string>` | —         | Override built-in UI label strings (e.g. `play`, `pause`, `fullscreen`, etc.)    |
| `speed`       | `{ rates?: number[] }`   | `{ rates: [0.5, 0.75, 1, 1.25, 1.5, 2] }` | Playback speed options shown in the Settings menu |
| `controls`    | `ControlsConfig`         | see below | Layout of the built-in controls and auto-hide behaviour                          |

> For engine/plugins and initial playback state options like `plugins`, `startTime`, `startVolume`, `startPlaybackRate`, and `duration`, see `@openplayerjs/core`.

---

### Labels reference

All label strings are owned by `@openplayerjs/player` — there are no labels in `@openplayerjs/core`. Pass any subset of the keys below via `labels` in the player config; omitted keys keep their default English value.

Two keys accept a `%s` placeholder that is replaced at runtime with a dynamic value (time or percentage).

| Key               | Default              | Where it appears                                                                                                     |
| ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `auto`            | `'Auto'`             | Reserved key — not currently used by any built-in control                                                            |
| `back`            | `'Back'`             | Settings sub-menu — back/close button accessible label                                                               |
| `captions`        | `'CC/Subtitles'`     | Captions section header inside the Settings menu                                                                     |
| `captionsOff`     | `'Captions off'`     | Screen reader live-region announcement when captions are turned off                                                  |
| `captionsOn`      | `'Captions on'`      | Screen reader live-region announcement when captions are turned on                                                   |
| `click`           | `'Click to unmute'`  | Autoplay-muted overlay prompt text on desktop                                                                        |
| `container`       | `'Media player'`     | `aria-label` on the outermost `.op-player` wrapper element                                                           |
| `enterFullscreen` | `'Enter Fullscreen'` | Screen reader live-region announcement on entering fullscreen                                                        |
| `exitFullscreen`  | `'Exit Fullscreen'`  | Fullscreen button tooltip/title while in fullscreen mode                                                             |
| `fullscreen`      | `'Fullscreen'`       | Fullscreen button tooltip/title and initial screen reader label                                                      |
| `live`            | `'Live'`             | Text shown in the duration/time display for live streams                                                             |
| `loading`         | `'Loading...'`       | `aria-label` on the loading-spinner overlay element                                                                  |
| `media`           | `'Media'`            | `aria-label` on the inner `.op-media` container element                                                              |
| `mute`            | `'Mute'`             | Mute button tooltip/title and screen reader label when sound is on                                                   |
| `off`             | `'Off'`              | "Off" option label in the captions Settings sub-menu                                                                 |
| `pause`           | `'Pause'`            | Play button tooltip/title and screen reader label while playing                                                      |
| `play`            | `'Play'`             | Play button tooltip/title and screen reader label while paused; also the centre-overlay button label                 |
| `progressRail`    | `'Time Rail'`        | `aria-label` on the progress bar container (the "rail")                                                              |
| `progressSlider`  | `'Time Slider'`      | `aria-label` on the draggable seek `<input type="range">`                                                            |
| `restart`         | `'Restart'`          | Play button tooltip/title and screen reader label when the video has ended                                           |
| `seekTo`          | `'Seek to %s'`       | Screen reader live-region announcement on seek — `%s` is replaced with the formatted time (e.g. `'1:23'`)            |
| `settings`        | `'Player Settings'`  | Settings button tooltip/title and screen reader label                                                                |
| `speed`           | `'Speed'`            | Root Settings menu row label and sub-menu header for playback speed                                                  |
| `speedNormal`     | `'Normal'`           | Label for the 1× playback rate option in the speed sub-menu                                                          |
| `tap`             | `'Tap to unmute'`    | Autoplay-muted overlay prompt text on mobile                                                                         |
| `toggleCaptions`  | `'Toggle Captions'`  | Captions button tooltip/title and screen reader label                                                                |
| `unmute`          | `'Unmute'`           | Mute button tooltip/title and screen reader label while muted                                                        |
| `volume`          | `'Volume'`           | Used as the prefix inside the `volumePercent` announcement template                                                  |
| `volumeControl`   | `'Volume Control'`   | `aria-label` on the volume slider wrapper `<div>` (the ARIA `role="slider"` container)                               |
| `volumePercent`   | `'Volume: %s%'`      | Screen reader live-region announcement on volume change — `%s` is replaced with the integer percentage (e.g. `'75'`) |
| `volumeSlider`    | `'Volume Slider'`    | `aria-label` on the underlying volume `<input type="range">`                                                         |

**Example — override labels for Spanish:**

```js
// ESM
const core = new Core(video, {
  labels: {
    play: 'Reproducir',
    pause: 'Pausar',
    restart: 'Volver a reproducir',
    mute: 'Silenciar',
    unmute: 'Activar sonido',
    fullscreen: 'Pantalla completa',
    exitFullscreen: 'Salir de pantalla completa',
    settings: 'Configuración',
    toggleCaptions: 'Subtítulos',
    live: 'En directo',
    loading: 'Cargando...',
    seekTo: 'Ir a %s',
    volumePercent: 'Volumen: %s%',
  },
});
```

```js
// UMD
var player = new OpenPlayerJS('player', {
  labels: {
    play: 'Reproducir',
    pause: 'Pausar',
    mute: 'Silenciar',
    unmute: 'Activar sonido',
  },
});
player.init();
```

---

### Speed configuration

The `speed` option controls which playback rates appear in the Settings menu. The root menu row always shows the currently active speed next to the "Speed" label, and a checkmark appears when the rate differs from 1× (Normal).

```js
// ESM — show only a compact set of speeds
const core = new Core(video, {
  speed: { rates: [0.5, 1, 1.5, 2] },
});
```

```js
// UMD
var player = new OpenPlayerJS('player', {
  speed: { rates: [0.5, 1, 1.5, 2] },
});
player.init();
```

---

### Controls configuration

When using the `OpenPlayerJS` class (UMD / script tag), a default controls layout is applied automatically when `controls` is not provided:

```js
controls: {
  top: ['progress'],
  'bottom-left': ['play', 'time', 'volume'],
  'bottom-right': ['captions', 'settings', 'fullscreen'],
}
```

You can fully override the layout using the **flat format** (same keys accepted by `buildControls`):

```js
const player = new OpenPlayerJS('video', {
  controls: {
    top: ['progress'],
    'bottom-left': ['play', 'time', 'volume'],
    'bottom-right': ['captions', 'settings', 'fullscreen'],
  },
});
```

#### Legacy `layers` format

The previous `layers`-based configuration is also supported for backwards compatibility. The keys `left`, `middle`, and `right` map to `bottom-left`, `top`, and `bottom-right` respectively:

```js
const player = new OpenPlayerJS('video', {
  controls: {
    layers: {
      left: ['play', 'time', 'volume'],
      middle: ['progress'],
      right: ['captions', 'settings', 'fullscreen'],
    },
  },
});
```

#### `alwaysVisible`

By default the control bar auto-hides after 3 seconds of inactivity during playback. Set `alwaysVisible: true` to keep the controls permanently visible:

```js
const player = new OpenPlayerJS('video', {
  controls: {
    alwaysVisible: true,
    top: ['progress'],
    'bottom-left': ['play', 'time', 'volume'],
    'bottom-right': ['captions', 'settings', 'fullscreen'],
  },
});
```

`alwaysVisible` can be combined with both the flat format and the `layers` format.

---

## What's inside?

| Export                     | Purpose                                                                                           |
| -------------------------- | ------------------------------------------------------------------------------------------------- |
| `createUI`                 | Mounts the player wrapper, centre overlay, and control grid into the DOM                          |
| `buildControls`            | Resolves a layout config object into an array of `Control` instances                              |
| `registerControl`          | Registers a custom control factory globally, making it usable by string ID in `buildControls`     |
| `extendControls`           | Attaches the `player.controls` imperative API (`addElement`, `addControl`) to a `Player` instance |
| `createPlayControl`        | Factory for the built-in play/pause button                                                        |
| `createVolumeControl`      | Factory for the volume slider and mute/unmute button                                              |
| `createProgressControl`    | Factory for the seek bar with current-time tooltip                                                |
| `createCurrentTimeControl` | Factory for the current playback position display (e.g. `1:23`)                                   |
| `createDurationControl`    | Factory for the total duration display (e.g. `5:00`)                                              |
| `createTimeControl`        | Factory for the combined time display (e.g. `1:23 / 5:00`)                                        |
| `createCaptionsControl`    | Factory for the captions/subtitle toggle button                                                   |
| `createSettingsControl`    | Factory for the settings menu (speed, caption language)                                           |
| `createFullscreenControl`  | Factory for the fullscreen toggle                                                                 |
| `BaseControl`              | Base class you can extend to share common control lifecycle logic (**for dev purposes only**)     |

---

## Stylesheet

The UI ships a standalone CSS file. Import it once per application:

### Bundler (Vite / webpack / esbuild) — recommended

The package exposes a `./openplayer.css` export entry that resolves to `dist/openplayer.css` via the `exports` map in `package.json`. Use this path in any modern bundler:

```ts
import '@openplayerjs/player/openplayer.css';
```

If your bundler does not support package `exports` (older webpack 4, some legacy setups), reference the `dist/` path directly:

```ts
import '@openplayerjs/player/dist/openplayer.css';
```

### CSS `@import` (CodePen, CSS entry files)

In environments where you write CSS directly (a CodePen pen, a plain `.css` entry file, a `<style>` tag), use a regular CSS `@import` with the CDN URL:

```css
@import url('https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.css');
```

### `<link>` tag (CDN / plain HTML)

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.css" />
```

> All player elements use the `op-` CSS prefix. You can override any variables or classes in your own stylesheet. No `!important` should be needed for most overrides.

---

## Built-in control IDs

Use these string IDs in `buildControls` to place the built-in controls:

| ID            | Description                                                                   |
| ------------- | ----------------------------------------------------------------------------- |
| `play`        | Play / Pause toggle button                                                    |
| `volume`      | Volume slider + Mute / Unmute button                                          |
| `progress`    | Seek bar with a current-time tooltip                                          |
| `time`        | Combined current time / duration display (e.g. `1:23 / 5:00`)                 |
| `currentTime` | Current playback position only (e.g. `1:23`)                                  |
| `duration`    | Total duration only (e.g. `5:00`)                                             |
| `captions`    | Caption / subtitle toggle button                                              |
| `settings`    | Settings menu (speed, caption language selection if `captions` are activated) |
| `fullscreen`  | Fullscreen toggle                                                             |

> **`time` vs separate `currentTime` + `duration`:** Use `'time'` for the classic combined display. Use `'currentTime'` and `'duration'` individually when you want to place them in different positions or style them independently.

The built-in keyboard handling is active whenever the player wrapper has focus. You can override the `step` config option to change seek distances.

| Key               | Action                                                             |
| ----------------- | ------------------------------------------------------------------ |
| `Space` / `Enter` | Play / Pause (when player has focus)                               |
| `K`               | Play / Pause                                                       |
| `M`               | Mute / Unmute                                                      |
| `F`               | Toggle fullscreen                                                  |
| `←` (Left arrow)  | Seek back 5 s (or the configured `step` value)                     |
| `→` (Right arrow) | Seek forward 5 s (or the configured `step` value)                  |
| `J`               | Seek back 10 s (or double the configured `step` value)             |
| `L`               | Seek forward 10 s (or double the configured `step` value)          |
| `↑` (Up arrow)    | Volume up                                                          |
| `↓` (Down arrow)  | Volume down                                                        |
| `Home`            | Seek to the beginning                                              |
| `End`             | Seek to the end of the media (no-op for live streams)              |
| `0`–`9`           | While the progress bar has focus: seek to 0%–90% of total duration |
| `,`               | While paused: step back one frame                                  |
| `.`               | While paused: step forward one frame                               |
| `<`               | Slow down playback rate by `0.25`                                  |
| `>`               | Speed up playback rate by `0.25`                                   |

---

## Player layout

The v3 UI renders the following DOM structure inside the player wrapper:

```
.op-player                  ← outer wrapper (position: relative)
├── .op-player__media       ← your original <video> / <audio> element
├── .op-player__overlay     ← centre overlay (play icon, pause flash, loader)
└── .op-player__controls    ← control bar
    ├── [top row]           ← optional, only rendered when you add top controls
    ├── [main row]          ← holds the progress bar by default
    └── [bottom row]        ← holds play, volume, time, captions, fullscreen, etc.
        ├── [left slot]
        ├── [middle slot]
        └── [right slot]
```

---

## Control placement

`buildControls` accepts an object with position:

```ts
const controls = buildControls({
  'top-left': [],
  'top-middle': [], // this is the same as 'top'
  'top-right': [],
  'center-left': ['progress'], // full-width row
  'center-middle': [], // this is the same as 'center'
  'center-right': [], // full-width row
  'bottom-left': ['play', 'currentTime', 'volume'],
  'bottom-middle': [], // this is the same as 'bottom'
  'bottom-right': ['duration', 'captions', 'settings', 'fullscreen'],
});
```

Omitting a slot or leaving it as an empty array means nothing would be rendered there.

---

## ESM API

> **UMD / CDN users:** the functions in this section are ESM exports — you do not call them directly. The `OpenPlayerJS` wrapper invokes `buildControls`, `createUI`, and `extendControls` internally when you call `player.init()`. For the UMD-specific API see the [UMD API reference](#umd-api-reference).

### `createUI(player, media, controls)`

Mounts the player's DOM structure. Call this after creating your `Player` instance and building your controls:

```ts
createUI(player, video, controls);
```

After `createUI` runs, the original media element is wrapped inside `.op-player`, the center overlay and control grid are injected, and each control's `create(player)` factory is called to render the buttons.

### `buildControls(config?)`

Converts a controls configuration into an array of `Control` instances that `createUI` can render. Calling it with no argument (or an empty object) applies the default layout automatically.

`buildControls` accepts **three equivalent formats**:

**Default** — omit the argument entirely:

```ts
const controls = buildControls(); // progress on top, play/time/volume left, captions/settings/fullscreen right
```

**Flat format** — explicit slot keys (`top`, `bottom-left`, `bottom-right`, …):

```ts
const controls = buildControls({
  top: ['progress'],
  'bottom-left': ['play', 'time', 'volume'],
  'bottom-right': ['captions', 'settings', 'fullscreen'],
});
```

**Layers format** — semantic `left / middle / right` keys (maps to `bottom-left / top / bottom-right`):

```ts
const controls = buildControls({
  layers: {
    left: ['play', 'time', 'volume'],
    middle: ['progress'],
    right: ['captions', 'settings', 'fullscreen'],
  },
});
```

Non-array properties (e.g. `alwaysVisible`) are silently ignored by `buildControls` so you can pass the same config object to both `buildControls` and `createUI`:

```ts
import { buildControls, createUI } from '@openplayerjs/player';

const config = {
  layers: { left: ['play', 'volume'], middle: ['progress'], right: ['fullscreen'] },
  alwaysVisible: true,
};

createUI(core, video, buildControls(config), { alwaysVisible: config.alwaysVisible });
```

The slot key format is a vertical position (`top`, `center`/`middle`, `bottom`) optionally joined with a horizontal position (`left`, `center`, `right`) by a hyphen. Each slot maps to an array of built-in control IDs or IDs registered via `registerControl`.

Valid vertical slots: `top`, `center` (alias `middle`), `bottom`.
Valid horizontal slots: `left`, `center` (alias `middle`), `right`.
Omit the horizontal part to default to center (e.g. `'top'` = `'top-center'`).

> **ESM + manual placement:** If you build the controls array yourself (instead of using `buildControls`), pass each control's `placement` directly on the object:
>
> ```ts
> import { createPlayControl, createProgressControl, createFullscreenControl } from '@openplayerjs/player';
>
> const controls = [
>   createPlayControl({ v: 'bottom', h: 'left' }),
>   createProgressControl({ v: 'top', h: 'left' }),
>   createFullscreenControl({ v: 'bottom', h: 'right' }),
> ];
>
> createUI(core, video, controls);
> ```

### `registerControl(id, factory)`

Registers a custom control globally so it can be referenced by string ID in `buildControls`:

```ts
import { registerControl } from '@openplayerjs/player';

registerControl('my-button', () => ({
  id: 'my-button',
  placement: { v: 'bottom', h: 'right' },
  create(player) {
    const btn = document.createElement('button');
    btn.textContent = 'My Action';
    btn.onclick = () => player.pause();
    return btn;
  },
}));

// Now usable by ID:
buildControls({ 'bottom-right': ['my-button', 'fullscreen'], top: ['progress'] });
```

### `extendControls(core)`

Attaches the `.controls` imperative API (`addElement` and `addControl`) to a `Core` instance. Call this once, **after** `createUI`, in ESM contexts where you are managing the setup manually:

```ts
import { createUI, buildControls, extendControls } from '@openplayerjs/player';

const core = new Core(video, { plugins: [] });
createUI(core, video, buildControls());
extendControls(core); // adds core.controls.addElement and core.controls.addControl

// core.controls is now available:
core.controls.addElement(element, { v: 'top', h: 'right' });
core.controls.addControl(myControl);
```

> **UMD users:** you do not call `extendControls` — `player.init()` calls it internally. The resulting API is then available directly as `player.addElement()` and `player.addControl()` (not as `player.controls.*`). See [`addElement` and `addControl` — UMD vs ESM](#addelement-and-addcontrol--umd-vs-esm).

> **Important:** `extendControls` (and `addElement` / `addControl`) can only be called after `createUI` has run. Calling them before the UI is initialized will throw, because the DOM event listeners do not yet exist.

### `addElement` and `addControl` — UMD vs ESM

`addElement` and `addControl` exist in both environments but through different paths.

> **`player.controls` does not exist on the UMD `OpenPlayerJS` wrapper.** There are no `player.controls.addElement()` or `player.controls.addControl()` methods — calling them will throw `TypeError: player.controls is undefined`. Use the direct wrapper shortcuts `player.addElement()` and `player.addControl()` instead, or access the Core API via `player.getCore().controls.*`.

| Action                  | UMD                                                    | ESM (after `extendControls(core)`)         |
| ----------------------- | ------------------------------------------------------ | ------------------------------------------ |
| Place an HTML element   | `player.addElement(el, placement?)`                    | `core.controls.addElement(el, placement?)` |
| Mount a typed control   | `player.addControl(control)`                           | `core.controls.addControl(control)`        |
| Via Core directly (UMD) | `player.getCore().controls.addElement(el, placement?)` | — you already have `core`                  |
| Via Core directly (UMD) | `player.getCore().controls.addControl(control)`        | — you already have `core`                  |

All paths call the same underlying `extendControls` API. The UMD shortcuts (`player.addElement`, `player.addControl`) are thin wrappers around `this.core.controls.*` — use whichever is less verbose for your context.

#### `addElement(el, placement?)` — place any element

For watermarks, brand logos, overlays, and any content that is not a control-bar button:

```js
// UMD — direct wrapper method
var badge = document.createElement('span');
badge.textContent = '● LIVE';
player.addElement(badge, { v: 'top', h: 'right' });
```

```ts
// ESM — Core controls API (after extendControls(core))
import { extendControls } from '@openplayerjs/player';
extendControls(core);

const badge = document.createElement('span');
badge.textContent = '● LIVE';
core.controls.addElement(badge, { v: 'top', h: 'right' });
```

| Argument    | Type                                                                     | Description                                                  |
| ----------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ |
| `el`        | `HTMLElement`                                                            | The DOM element to insert                                    |
| `placement` | `{ v: 'top' \| 'middle' \| 'bottom', h: 'left' \| 'center' \| 'right' }` | Where to place it. Defaults to `{ v: 'bottom', h: 'right' }` |

> **Security note:** Because you create the DOM element yourself with standard browser APIs, there is no risk of XSS when you use `document.createElement`, `textContent`, or `appendChild`. Avoid `.innerHTML` even for content you consider trusted — prefer building the element tree with DOM APIs instead. If you do use `.innerHTML`, the string must be your own static markup, never data derived from user input, a URL parameter, or an API response.

#### `addControl(control)` — mount a typed control

For interactive buttons (skip intro, next episode, quality picker, etc.). Always appended to the end of its slot — use `registerControl` when ordering relative to built-in controls matters:

```js
// UMD — direct wrapper method
player.addControl({
  id: 'skip-intro',
  placement: { v: 'bottom', h: 'right' },
  create: function (core) {
    var btn = document.createElement('button');
    btn.textContent = 'Skip Intro';
    btn.onclick = function () {
      core.currentTime = 90;
    };
    return btn;
  },
});
```

```ts
// ESM — Core controls API (after extendControls(core))
import type { Control } from '@openplayerjs/player';
import { extendControls } from '@openplayerjs/player';
extendControls(core);

const skipIntro: Control = {
  id: 'skip-intro',
  placement: { v: 'bottom', h: 'right' },
  create(core) {
    const btn = document.createElement('button');
    btn.textContent = 'Skip Intro';
    btn.onclick = () => (core.currentTime = 90);
    return btn;
  },
};

core.controls.addControl(skipIntro);
```

---

## Adding a custom element

Use `addElement` to place any HTML element you create at a specific position in the player. This is the right approach for watermarks, brand logos, chapter markers, or anything that is not a button in the control bar.

> **Accessibility:** use `setA11yLabel` on non-interactive elements that need an accessible description for screen readers.

```js
// UMD — player.addElement() is a direct method on the OpenPlayerJS wrapper
var badge = document.createElement('div');
badge.className = 'my-live-badge';
OpenPlayerJS.setA11yLabel(badge, 'Streaming status');
badge.textContent = '● LIVE';

player.addElement(badge, { v: 'top', h: 'right' }); // after player.init()
```

```ts
// ESM — extendControls adds core.controls; then call core.controls.addElement()
import { createUI, buildControls, extendControls, setA11yLabel } from '@openplayerjs/player';

createUI(core, video, buildControls());
extendControls(core); // adds core.controls.addElement and core.controls.addControl

const badge = document.createElement('div');
badge.className = 'my-live-badge';
setA11yLabel(badge, 'Streaming status');
badge.textContent = '● LIVE';

core.controls.addElement(badge, { v: 'top', h: 'right' });
```

The `placement` argument:

| Key | Values                              | Description                              |
| --- | ----------------------------------- | ---------------------------------------- |
| `v` | `'top'` \| `'middle'` \| `'bottom'` | Vertical position relative to the player |
| `h` | `'left'` \| `'center'` \| `'right'` | Horizontal position within that row      |

> **Security note:** Because you create the DOM element yourself with standard browser APIs (`document.createElement`, `element.textContent`, `appendChild`, etc.), there is no risk of XSS. Avoid `.innerHTML` and `.outerHTML` even for content you consider trusted — building the element tree with DOM APIs is always the safer and recommended approach. If you must use `.innerHTML`, the string must be your own static markup, never data derived from user input, a URL parameter, or an API response.

---

## Accessibility

### `setA11yLabel` and `op-player__sr-only`

All built-in controls use `setA11yLabel(element, labelText)` (exported from `@openplayerjs/player`) to attach accessible names to DOM elements in a screen-reader-friendly way.

**What it does:**

- For `<button>` elements it injects a `<span class="op-player__sr-only">` _inside_ the button and sets its `textContent` to the label string. The button's own `aria-label` attribute is removed so the screen reader reads the span text instead.
- For non-button elements (sliders, containers, etc.) it creates a `<span class="op-player__sr-only">` _sibling_ and wires up `aria-labelledby` on the target element.

**Why the span text is invisible:** `op-player__sr-only` is a standard visually-hidden utility class. It positions the element off-screen with a 1 px clip so it takes up no visual space but is still read aloud by screen readers. This is why `span.textContent = "Toggle Captions"` does not appear on screen — it is exclusively for assistive technology.

**UMD access:** `setA11yLabel` is available as a static method on the `OpenPlayerJS` global — no import needed:

```js
OpenPlayerJS.setA11yLabel(btn, 'My Action');
```

**Using `setA11yLabel` in a custom control:**

```ts
import { setA11yLabel } from '@openplayerjs/player';

const btn = document.createElement('button');
btn.className = 'op-control__my-button';
// This inserts a visually-hidden <span>My Action</span> inside the button.
// Screen readers announce "My Action" when the button gains focus.
setA11yLabel(btn, 'My Action');
```

You can apply the class yourself on any element that should be visible only to screen readers:

```js
// UMD
var span = document.createElement('span');
span.className = 'op-player__sr-only';
span.textContent = 'Switch to source';
btn.appendChild(span);
// setA11yLabel(btn, 'Switch to source') produces the same result
```

### Available CSS utility classes

| Class                  | Purpose                                                                                                        |
| ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| `op-player__sr-only`   | Visually hidden, still accessible to screen readers. Use for labels and hint text inside interactive elements. |
| `op-player__announcer` | Added automatically to the live-region nodes that `getSharedAnnouncer` manages. Do not set this manually.      |

---

## Writing a custom control

> **Recommendation:** As best practice, when adding a custom control, to make it compliant with the WCAG 2.2 standards, use the `setA11yLabel` method to properly set ARIA-\* elements

A `Control` is a plain object (or class instance) with this shape:

```ts
import type { Core } from '@openplayerjs/core';
import type { Control, ControlPlacement } from '@openplayerjs/player';
import { setA11yLabel } from '@openplayerjs/player';

function createMyControl(): Control {
  return {
    id: 'my-control',
    placement: { v: 'bottom', h: 'right' } satisfies ControlPlacement,

    create(core: Core): HTMLElement {
      const btn = document.createElement('button');
      btn.className = 'op-control__my-control';
      setA11yLabel(btn, 'My action');
      btn.textContent = 'Do it';
      btn.addEventListener('click', () => core.pause());
      return btn;
    },

    destroy() {
      // Optional: clean up any timers or subscriptions you set up in create().
    },
  };
}
```

If you want to share a control across multiple player instances, package it as a factory function:

```ts
import type { Control, ControlPlacement } from '@openplayerjs/player';

function createNextEpisodeControl(onNext: () => void): Control {
  return {
    id: 'next-episode',
    placement: { v: 'bottom', h: 'right' } satisfies ControlPlacement,
    create(player) {
      const btn = document.createElement('button');
      btn.className = 'op-control__next-episode';
      btn.setAttribute('aria-label', 'Next episode');
      btn.textContent = '⏭';
      btn.addEventListener('click', () => {
        player.pause();
        onNext();
      });
      return btn;
    },
  };
}

// ESM usage (after extendControls(core)):
core.controls.addControl(createNextEpisodeControl(() => loadNextEpisode()));

// UMD usage (after player.init()):
// player.addControl(createNextEpisodeControl(() => loadNextEpisode()));
```

---

The `Control` interface:

| Property    | Type                            | Required | Description                                                                                                                                                                                        |
| ----------- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`        | `string`                        | Yes      | Unique identifier used for tracking and deduplication                                                                                                                                              |
| `placement` | `ControlPlacement`              | Yes      | Where to place the control: `{ v: 'top' \| 'middle' \| 'bottom', h: 'left' \| 'center' \| 'right' }`. Unknown values for `h` silently fall through to `'right'`.                                   |
| `create`    | `(player: Core) => HTMLElement` | Yes      | Returns the rendered DOM element. The `player` / `core` argument is always passed; name it or ignore it as needed (see below)                                                                      |
| `destroy`   | `() => void`                    | No       | Called when the control is removed or the player is destroyed. **Must store the listener function reference** to remove it — `removeEventListener('click')` without a reference is a silent no-op. |

### `create` syntax forms

The `player` / `core` argument is **always passed** by `createUI` regardless of which form you choose. The only difference is whether your function names and uses it:

```js
// Form 1 — shorthand method (ES6+). Receives and uses the player instance.
// Use this when you need to call player.pause(), read player.currentTime, etc.
{
  create(player) {
    var btn = document.createElement('button');
    btn.onclick = function() { player.pause(); };
    return btn;
  }
}

// Form 2 — function expression that ignores the argument.
// Use this when the element has all its behaviour set up outside create(),
// or when it doesn't need to interact with the player at all.
{
  create: function() {
    return nextBtn; // nextBtn was built outside this object
  }
}

// Form 3 — function expression that names and uses the argument (ES5-compatible).
// Equivalent to Form 1, preferred in plain-HTML / CodePen / UMD contexts
// where arrow functions or shorthand methods may not be available.
{
  create: function(player) {
    var btn = document.createElement('button');
    btn.onclick = function() { player.pause(); };
    return btn;
  }
}
```

All three forms are valid. Forms 1 and 3 are identical in behaviour; Form 2 is fine when the element's event handlers are wired up before `create` is called.

---

## Registering a control globally

Use `registerControl` to make a custom control available by string ID in `buildControls`. This is the **only** way to control the exact slot and order of a custom control alongside built-in controls — `addControl()` called after `init()` always appends to the end of the slot, so pre-registration is required when ordering matters.

### ESM

```ts
import { registerControl, buildControls } from '@openplayerjs/player';

registerControl('next-episode', () => ({
  id: 'next-episode',
  placement: { v: 'bottom', h: 'right' }, // overridden by the slot key below
  create(player) {
    const btn = document.createElement('button');
    btn.textContent = '⏭';
    btn.onclick = () => console.log('next');
    return btn;
  },
}));

// Now you can reference it by ID and control its position via array order:
const controls = buildControls({
  'bottom-left': ['play', 'volume'],
  'bottom-right': ['next-episode', 'fullscreen'], // ← appears before fullscreen
});
```

### UMD — registering before `init()`

`registerControl` is available as a static method on the `OpenPlayerJS` global. Call it **before** `init()`, then reference the ID in the `controls` config to control its position:

```html
<script src="https://cdn.jsdelivr.net/npm/@openplayerjs/player@latest/dist/openplayer.js"></script>
<script>
  // 1. Register the factory before init()
  OpenPlayerJS.registerControl('my-ctrl', function () {
    return {
      id: 'my-ctrl',
      placement: { v: 'bottom', h: 'left' }, // overridden by the slot key below
      create: function (player) {
        var btn = document.createElement('button');
        btn.textContent = 'My Action';
        btn.onclick = function () {
          player.pause();
        };
        return btn;
      },
    };
  });

  // 2. Reference the ID in the controls layout — array order controls position
  var player = new OpenPlayerJS('player', {
    controls: {
      'bottom-left': ['play', 'my-ctrl', 'time', 'volume'], // my-ctrl between play and time
      'bottom-right': ['captions', 'settings', 'fullscreen'],
      top: ['progress'],
    },
  });

  player.init();
</script>
```

> **Why `addControl()` alone is not enough for ordering:** `addControl()` called after `init()` appends the element to the _end_ of a slot. The position string you put it at in the `controls` config is evaluated during `init()` — any ID not yet registered at that moment is silently skipped. Pre-register with `registerControl` before `init()` to control exact position.

---

## `registerControl` vs `addControl`

Both add a custom control to the player, but they serve different purposes and have different constraints.

|                      | `registerControl`                                                             | `addControl`                                                                                                                                                               |
| -------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **When to call**     | Before `init()` / `buildControls()`                                           | After `init()` / `createUI()`                                                                                                                                              |
| **What you pass**    | A string ID + a factory function `() => Control`                              | A fully-formed `Control` object (with `placement` required)                                                                                                                |
| **Ordering**         | Array position in the `controls` config determines slot order                 | Always appended to the **end** of the slot — cannot interleave with existing controls                                                                                      |
| **Primary use case** | Defining the initial layout, including position relative to built-in controls | Adding a control whose factory must close over state that only exists after `init()` — e.g. references to other player instances, async API data, or runtime feature flags |
| **UMD access**       | `OpenPlayerJS.registerControl(id, factory)` (static)                          | `player.addControl(control)` (instance, after `init()`)                                                                                                                    |
| **ESM access**       | `import { registerControl } from '@openplayerjs/player'`                      | `core.controls.addControl(control)` after `extendControls(core)`                                                                                                           |

> **Note:** `player.addControl()` in UMD and `core.controls.addControl()` in ESM are identical — the UMD wrapper is just a shortcut that delegates to `core.controls.addControl()`.

**Rule of thumb:** if you know the control and its position at page-load time, use `registerControl` + the `controls` config. Use `addControl` when:

- The control's factory must close over state that doesn't exist yet at page load — for example, a second player instance, a response from an API, or a flag set by another plugin.
- Exact slot ordering relative to built-in controls does not matter (the control will be appended to the end of its slot regardless).

```js
// Example: a "Switch source" button that references a second player.
// addControl is correct here because player2 only exists after player2.init().
var player1 = new OpenPlayerJS('player1', {
  /* … */
});
player1.init();

var player2 = new OpenPlayerJS('player2', {
  /* … */
});
player2.init();

function makeSwitchControl(from, to) {
  var handler;
  return {
    id: 'switch-source',
    placement: { v: 'bottom', h: 'right' },
    create: function () {
      var btn = document.createElement('button');
      var span = document.createElement('span');
      span.className = 'op-player__sr-only';
      span.textContent = 'Switch source';
      btn.appendChild(span);

      // Store the reference so destroy() can actually remove it.
      handler = function () {
        to.play();
        from.pause();
      };
      btn.addEventListener('click', handler);
      return btn;
    },
    destroy: function () {
      var btn = document.getElementById('switch-source');
      if (btn && handler) btn.removeEventListener('click', handler);
    },
  };
}

player1.addControl(makeSwitchControl(player1, player2));
player2.addControl(makeSwitchControl(player2, player1));
```

### Migrating from v2

In v2, `addControl` was the only way to add a custom control — it was called after `init()` with a configuration object. That calling convention is preserved in v3, which makes `addControl` the closer match for v2 code. However, the **API shape changed completely** and `registerControl` is an entirely new concept with no v2 equivalent.

|                        | v2                                                              | v3 `addControl`                                                 | v3 `registerControl`                            |
| ---------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------- |
| **When to call**       | After `init()`                                                  | After `init()` ✓ same                                           | Before `init()` — new concept                   |
| **Control definition** | Config object: `{ icon, content, title, alt, position, index }` | `Control` object: `{ id, placement, create() }` — shape changed | Factory function: `() => Control` — new concept |
| **Ordering**           | `index` property                                                | Appends to end of slot — `index` removed                        | Array position in `controls` config             |
| **Icon / content**     | HTML strings (`icon: '<svg>…'`) — XSS risk                      | `create()` builds the DOM element                               | Same                                            |
| **Tooltip / label**    | `title` / `alt` properties                                      | `btn.title` + `setA11yLabel(btn, '…')` on the element           | Same                                            |

**If you used v2 `addControl` and are migrating to v3:**

- Start with `addControl` — the post-`init()` call pattern is the same, and you can convert one control at a time.
- Replace `icon` / `content` HTML strings with a `create()` function that builds the element using `document.createElement`.
- Replace `title` / `alt` with `btn.title = '…'` and `setA11yLabel(btn, '…')` inside `create()`.
- Replace `position: 'right'` with `placement: { v: 'bottom', h: 'right' }` (see [valid placement values](#writing-a-custom-control)).
- If you relied on `index` for ordering: there is no direct equivalent in `addControl`. Switch to `registerControl` + the `controls` config array, which is the only way to control slot order in v3.

---

## Control tooltips

All built-in controls show a native browser tooltip on hover via the HTML `title` attribute. The tooltip text is taken from the same label that is used for the accessible name, so it reflects the current state:

| Control    | Tooltip changes to                        |
| ---------- | ----------------------------------------- |
| Play       | "Play" → "Pause" → "Restart" (on `ended`) |
| Mute       | "Mute" ↔ "Unmute"                         |
| Fullscreen | "Fullscreen" ↔ "Exit Fullscreen"          |
| Captions   | "Toggle Captions" (static)                |
| Settings   | "Player Settings" (static)                |

To override the text of any tooltip, pass a `labels` map to the player config:

```js
// ESM
const core = new Core(video, {
  labels: {
    play: 'Reproducir',
    pause: 'Pausar',
    fullscreen: 'Pantalla completa',
    exitFullscreen: 'Salir de pantalla completa',
    mute: 'Silenciar',
    unmute: 'Activar sonido',
    toggleCaptions: 'Subtítulos',
    settings: 'Configuración',
  },
});
```

```js
// UMD
var player = new OpenPlayerJS('player', {
  labels: {
    play: 'Reproducir',
    fullscreen: 'Pantalla completa',
  },
});
```

Custom controls receive tooltips the same way — set `btn.title` on the element you return from `create()`.

---

## Migration from v2 — removed properties (`title`, `alt`, `index`)

In v2, the `addControl` / `addElement` API accepted plain configuration objects with properties like `title`, `alt`, and `index`. **These properties no longer exist in v3.**

| v2 property             | v3 equivalent                                                                                                                                                  |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` / `alt`         | Set `btn.title = 'My label'` directly on the DOM element, or use `setA11yLabel(btn, 'My label')` for the accessible name. Both can coexist on the same button. |
| `index` / ordering      | Place controls in the desired slot position by putting them in the right array in `buildControls`. Array order within a slot determines render order.          |
| `icon` (HTML string)    | Create the icon as a real DOM element and return it from `create()`.                                                                                           |
| `content` (HTML string) | Build the element tree in `create()` using `document.createElement`.                                                                                           |

**Before (v2):**

```js
player.addControl({
  title: 'Next Episode',
  alt: 'Skip to next',
  icon: '<svg>…</svg>',
  index: 5,
  // …
});
```

**After (v3):**

```js
var nextBtn = document.createElement('button');
nextBtn.title = 'Next Episode'; // tooltip
setA11yLabel(nextBtn, 'Next Episode'); // screen reader label

// Preferred: build the icon with DOM APIs
var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// … configure icon …
nextBtn.appendChild(icon);

// Allowed but not recommended: innerHTML is permitted only for your own
// static markup — never for user-supplied data, URL params, or API responses.
// nextBtn.innerHTML = '<svg>…</svg>';

player.addControl({
  id: 'next-episode',
  placement: { v: 'bottom', h: 'right' },
  create: function () {
    return nextBtn;
  },
});
```

---

## Events

`createUI` emits the following events on `core.events` in addition to the standard media events documented in `@openplayerjs/core`. Listen with `core.events.on(event, handler)`.

### UI lifecycle events

| Event              | Payload                                            | Description                                                                                                                                                                                                                                         |
| ------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ui:controls:show` | —                                                  | Fired when the control bar becomes visible (auto-hide timer cancelled, user interaction, or playback paused/ended).                                                                                                                                 |
| `ui:controls:hide` | —                                                  | Fired when the control bar is hidden by the auto-hide timer. Not emitted when hiding is prevented (e.g. controls have keyboard focus, or playback is paused).                                                                                       |
| `ui:menu:open`     | —                                                  | Fired when any settings or captions menu is opened. Cancels the auto-hide timer while the menu is open.                                                                                                                                             |
| `ui:menu:close`    | —                                                  | Fired when the open menu is closed. Restarts the auto-hide timer.                                                                                                                                                                                   |
| `ui:addElement`    | `{ el: HTMLElement; placement: ControlPlacement }` | Imperative API — emitting this event places an arbitrary element into the player grid. Prefer `core.controls.addElement()` (ESM) or `player.addElement()` (UMD) over emitting this event directly.                                                  |
| `ui:addControl`    | `{ control: Control; el?: HTMLElement }`           | Imperative API — emitting this event mounts a `Control` into the control bar. The `el` field is filled in by `createUI` after mounting. Prefer `core.controls.addControl()` (ESM) or `player.addControl()` (UMD) over emitting this event directly. |

### Subscribing to control bar visibility

**ESM:**

```ts
import { Core } from '@openplayerjs/core';
import { createUI, buildControls } from '@openplayerjs/player';

const core = new Core(video, { plugins: [] });
createUI(core, video, buildControls());

core.events.on('ui:controls:show', () => {
  console.log('controls visible');
});

core.events.on('ui:controls:hide', () => {
  console.log('controls hidden');
});
```

**UMD:**

```js
var player = new OpenPlayerJS('player');
player.init();

// player.on() is a shorthand for player.getCore().events.on() and is safe
// to call before init() — listeners are queued and attached when init() runs.
player.on('ui:controls:show', function () {
  console.log('controls visible');
});

// Direct Core event bus access (after init() only):
player.getCore().events.on('ui:controls:hide', function () {
  console.log('controls hidden');
});
```

This is particularly useful for third-party engines or overlays (such as the YouTube IFrame engine) that need to adjust their layout when the control bar appears or disappears.

---

## Code samples

A wide collection of ready-to-run examples — from basic setup to advanced controls customisation, plugins, and accessibility patterns — is available as a living cookbook in the CodePen collection below.

CodePen Collection: [https://codepen.io/collection/KwqaKQ](https://codepen.io/collection/KwqaKQ)

---

## License

MIT — see [LICENSE](../../LICENSE).
