npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@openplayerjs/player

v3.5.2

Published

UI layer, built-in controls, and accessibility for OpenPlayerJS

Readme

@openplayerjs/player

UI layer, built-in controls, and extension APIs for OpenPlayerJS

npm npm downloads License TypeScript JSDelivr

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).
  • UMD (CDN / plain <script> tag): load a <script> and use the OpenPlayerJS global. Start at Quick start (UMD) or jump straight to the 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 for a complete before/after comparison.


Installation

npm install @openplayerjs/player @openplayerjs/core

Quick start (ESM / bundlers)

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.

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

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:

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

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. | | OpenPlayerJS.setA11yLabel(el, label) | (el: HTMLElement, label: string) => void | Attach a visually-hidden accessible label to an element. See Accessibility. |

Constructor

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

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

Instance methods

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 | 01 | | player.muted | boolean | | | player.playbackRate | number | 1 = normal speed |

Events in UMD

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

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:

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:

// 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%',
  },
});
// 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).

// ESM — show only a compact set of speeds
const core = new Core(video, {
  speed: { rates: [0.5, 1, 1.5, 2] },
});
// 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:

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):

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:

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:

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:

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

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

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:

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

<link> tag (CDN / plain 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) | | 09 | 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:

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.

createUI(player, media, controls)

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

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:

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

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

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):

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:

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:

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:

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:

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

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:

// UMD — direct wrapper method
var badge = document.createElement('span');
badge.textContent = '● LIVE';
player.addElement(badge, { v: 'top', h: 'right' });
// 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:

// 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;
  },
});
// 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.

// 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()
// 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:

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

Using setA11yLabel in a custom control:

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:

// 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:

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:

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:

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

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:

<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).
// 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).
  • 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:

// 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',
  },
});
// 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):

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

After (v3):

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