Migrate from Mux Player
Move a Mux Player embed to Video.js v10, splitting one element into a player, a media, and a skin
Mux Player is one element that does everything. You drop in <mux-player> with a playback ID and you get HLS playback, a themed UI, analytics, captions, Chromecast, AirPlay, and keyboard shortcuts, all wired together.
Video.js v10 asks you to name the pieces you want. That sounds like more work, and for the first five minutes it is. In exchange, the player you ship only contains what you asked for, and when you need to change how something behaves you can open it up instead of hoping there’s an attribute for it.
This guide walks you from a working Mux Player embed to a working Video.js player, then covers the things you’ll reach for next.
Three pieces instead of one
Mux Player packs three jobs into a single element. Video.js splits them up, so it helps to learn the names before you write any code.
The player is the outer element. It holds state, hands that state to everything inside it, and draws nothing itself. Which state it holds depends on the features it’s built from.
The media is the thing that plays the video. The Mux media is the one you want: it knows what a playback ID is and how to talk to Mux. Swap it for another media component and the rest of your player keeps working.
The skin is the UI: the controls, the poster, the captions, the settings menu, the keyboard shortcuts. Skins are pre-built arrangements of smaller components, and you can use one as-is, restyle it, or take it apart.
So a Mux Player embed becomes a player wrapped around a skin wrapped around a media:
<video-player>
<video-skin>
<mux-video></mux-video>
</video-skin>
</video-player><VideoPlayer>
<VideoSkin>
<MuxVideo />
</VideoSkin>
</VideoPlayer>Everything else in this guide is about which of those three a given Mux Player attribute now belongs to.
Your first player
Here’s a typical Mux Player embed. A playback ID, a title and viewer ID for analytics, and a poster pulled from two seconds in.
<script src="https://cdn.jsdelivr.net/npm/@mux/mux-player" defer></script>
<mux-player
playback-id="EcHgOK9coz5K…"
metadata-video-title="Test VOD"
metadata-viewer-user-id="user-id-007"
thumbnail-time="2"
></mux-player>A preset is a player, a skin, and a media element that already fit together. Everything else is its own import. From the CDN, that’s one script for the video preset, one for the Mux media, and one for analytics:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/media/mux-video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/media/mux-data.js"></script>
<video-player>
<video-skin class="aspect-video">
<mux-video src="https://stream.mux.com/EcHgOK9coz5K….m3u8" playsinline crossorigin="anonymous"></mux-video>
<mux-data player-software-name="my-app"></mux-data>
<img slot="poster" src="https://image.mux.com/EcHgOK9coz5K…/thumbnail.webp?time=2" alt="" />
</video-skin>
</video-player>
<script type="module">
document.querySelector('mux-data').metadata = {
video_title: 'Test VOD',
viewer_user_id: 'user-id-007',
};
</script>If you use a bundler instead of the CDN, the imports are the same three pieces:
import '@videojs/html/video/player';
import '@videojs/html/video/skin';
import '@videojs/html/video/skin.css';
import '@videojs/html/media/mux-video';
import '@videojs/html/media/mux-data';import MuxPlayer from '@mux/mux-player-react';
export function MyPlayer() {
return (
<MuxPlayer
playbackId="EcHgOK9coz5K…"
metadata={{ video_title: 'Test VOD', viewer_user_id: 'user-id-007' }}
thumbnailTime={2}
/>
);
}@videojs/react gives you real React components rather than one component wrapping a custom element.
npm install @videojs/reactOne new idea here. Instead of one component that does everything, you pick a preset that matches what you’re building. A preset is a player, a skin, and a media element that already fit together. @videojs/react/video is the general-purpose one, and it’s what you want unless you’re doing something unusual.
'use client';
import '@videojs/react/video/skin.css';
import { VideoPlayer, VideoSkin } from '@videojs/react/video';
import { MuxData } from '@videojs/react/media/mux-data';
import { MuxVideo } from '@videojs/react/media/mux-video';
export function MyPlayer() {
return (
<VideoPlayer>
<VideoSkin className="aspect-video" poster="https://image.mux.com/EcHgOK9coz5K…/thumbnail.webp?time=2">
<MuxVideo source={{ playbackId: 'EcHgOK9coz5K…' }} playsInline crossOrigin="anonymous" />
<MuxData playerSoftwareName="my-app" metadata={{ video_title: 'Test VOD', viewer_user_id: 'user-id-007' }} />
</VideoSkin>
</VideoPlayer>
);
}VideoPlayer is the piece that owns the state. To read that state, the preset ships a matching usePlayer hook.
The 'use client' directive is there because the player owns browser state.
A few things worth calling out:
- The poster is yours to build. Mux Player derived one from the playback ID; here you point the skin at an
image.mux.comURL, andthumbnail-time="2"becomes?time=2.MuxVideoderives the same URL and reports it, but the poster is a skin concern today, so nothing reads it for you. - You didn’t add a storyboard track, and you get hover previews. The Mux media adds and maintains the thumbnail track itself, and removes it for live streams, where storyboards don’t exist.
- Analytics needs no environment key. Mux resolves the environment from the playback ID. See Mux Data.
- Set the aspect ratio yourself, in your own CSS on the skin. There’s no
ratioattribute, because sizing belongs to your layout.
Two things are opt-in that used to be automatic. Analytics is the Mux Data component, and Chromecast is a Cast component. Leave either out and you don’t ship its code. That, incidentally, is the answer to Mux Player’s disable-tracking: don’t include Mux Data.
Mux settings live in a source object
Mux Player has an attribute for every Mux stream parameter: max-resolution, asset-start-time, custom-domain, and a dozen more. Video.js collects them into a single source object that describes what to play and how.
{
playbackId: 'EcHgOK9coz5K…',
playback: { maxResolution: '1080p', assetStartTime: 10, assetEndTime: 30 },
poster: { time: 2, width: 1280 },
}The groups map to the three URLs Mux serves. playback modifies the video stream, poster modifies the still image, and storyboard modifies the hover-preview track. Video.js builds all three URLs and converts your camel-case keys to the snake_case query parameters Mux expects, so assetStartTime goes out as asset_start_time.
Assign it as a property, since an object has no attribute form:
document.querySelector('mux-video').source = { playbackId: 'EcHgOK9coz5K…' };You can also skip the object entirely and set src to a full Mux URL. The element parses it back into a source, query parameters included, which is what you want in markup you can’t run JavaScript against:
<mux-video src="https://stream.mux.com/EcHgOK9coz5K….m3u8?max_resolution=1080p"></mux-video>There’s no playback-id attribute, so declarative markup uses the URL form.
Pass it as the source prop:
<MuxVideo source={{ playbackId: 'EcHgOK9coz5K…' }} />You can also skip the object and pass src a full Mux URL. The component parses it back into a source, query parameters included:
<MuxVideo src="https://stream.mux.com/EcHgOK9coz5K….m3u8?max_resolution=1080p" />There’s no playbackId prop of its own; it lives on source.
Here’s where each Mux Player attribute lands:
| Mux Player | Video.js v10 |
|---|---|
playback-id |
source.playbackId |
custom-domain |
source.customDomain |
max-resolution, min-resolution |
source.playback.maxResolution, .minResolution |
rendition-order |
source.playback.renditionOrder |
asset-start-time, asset-end-time |
source.playback.assetStartTime, .assetEndTime |
program-start-time, program-end-time |
source.playback.programStartTime, .programEndTime |
default-subtitles-lang |
source.playback.defaultSubtitlesLang |
playback-token |
source.playback.token |
thumbnail-token, storyboard-token |
source.poster.token, source.storyboard.token |
drm-token |
source.drm.token |
thumbnail-time |
?time= on the poster URL you build |
| poster size, crop, rotation, format | query parameters on that URL |
Signed playback behaves the way it does in Mux Player: a token replaces every other parameter in its group, so caps and clipping have to be baked into the token itself.
Analytics moves to its own element
Mux Player’s analytics attributes become attributes and properties on the Mux Data component, placed inside the player.
| Mux Player | Video.js v10 |
|---|---|
metadata-*, metadata={…} |
the metadata property or prop |
env-key |
env-key, rarely needed for Mux-hosted content |
disable-cookies |
disable-cookies |
beacon-collection-domain |
beacon-collection-domain |
player-software-name, player-software-version |
player-software-name, player-software-version |
debug |
the debug property |
disable-tracking |
omit the component |
Your metadata keys don’t change. They’re the same snake_case names Mux Data has always taken, so the values port across untouched. metadata is a property rather than an attribute because an object has no sensible string form.
Titles and posters
metadata-video-title quietly did two jobs in Mux Player. It labelled the video for analytics, and it was the title the viewer read. Those are separate now, which is worth knowing before you go looking for the one attribute that used to do both:
- For analytics, it’s
metadata.video_titleon the Mux Data component. - For display, it’s
content-titleon the player.
content-title reaches player state, so your own UI can read it. No packaged skin draws it, so setting it alone won’t put a title on screen (#1123).
The poster is a skin concern. Build an image.mux.com URL and hand it over:
<video-skin placeholdersrc="data:image/webp;base64,…">
<mux-video src="https://stream.mux.com/EcHgOK9coz5K….m3u8"></mux-video>
<img slot="poster" src="https://image.mux.com/EcHgOK9coz5K…/thumbnail.webp?time=2" alt="" />
</video-skin>The poster is a slotted img rather than an attribute, which gives the skin control over how it appears. placeholdersrc is the blurred stand-in shown while the real poster loads, Mux Player’s placeholder.
<VideoSkin
poster="https://image.mux.com/EcHgOK9coz5K…/thumbnail.webp?time=2"
placeholder="data:image/webp;base64,…"
>
<MuxVideo source={{ playbackId: 'EcHgOK9coz5K…' }} />
</VideoSkin>placeholder is the blurred stand-in shown while the real poster loads, Mux Player’s placeholder.
Mux serves the poster from its thumbnail endpoint, so every Mux Player poster attribute becomes a query parameter you append: thumbnail-time is ?time=, and the size, crop, rotation, and format options work the same way. See the Poster reference for the component itself.
Customize your player
Mux Player gives you attributes and documented CSS variables. Past that, you’re stuck. Video.js gives you three levels, and you should try them in order.
Level 1: pick a skin
Two are packaged. The default skin is a modern, frosted look. The minimal skin is closer to a classic control bar. Both bring controls, tooltips, captions, keyboard shortcuts, touch gestures, and a settings menu that appears when there’s something to put in it. See Skins.
Level 2: restyle it
Set custom properties. The common case, a brand color:
/* Mux Player */
mux-player {
--accent-color: rebeccapurple;
}
/* Video.js v10 */
video-skin {
--media-accent-color: rebeccapurple;
}--media-accent-color reaches the sliders, the active buttons, and the accent surfaces. Video.js derives a readable text color to sit on top of it; override that with --media-accent-text-color if you’d rather choose. --media-border-radius rounds the player, and --media-scale-unit scales the whole control bar at once. Customize skins has the full list.
Level 3: eject
Ejecting means copying the skin’s source into your project and editing it, the way you would a shadcn component. The packaged skin isn’t compiled magic; it’s a tree of components, so ejecting starts as copy and paste. Customize skins has copy-paste-ready implementations for every packaged skin.
This is the level that per-control tweaks need, and it’s a genuine step up in effort from a Mux Player attribute. forward-seek-offset="30" was one character. In Video.js, the skins bake their seek step into their keyboard shortcuts and gestures, so changing it means editing those lines:
<media-hotkey keys="ArrowRight" action="seekStep" value="30"></media-hotkey>
<media-hotkey keys="ArrowLeft" action="seekStep" value="-30"></media-hotkey>
<media-gesture type="doubletap" action="seekStep" value="30" region="right"></media-gesture><Hotkey keys="ArrowRight" action="seekStep" value={30} />
<Hotkey keys="ArrowLeft" action="seekStep" value={-30} />
<Gesture type="doubletap" action="seekStep" value={30} region="right" />While you’re in there: neither packaged video skin includes skip buttons. Mux Player’s themes show them, so if your users expect them, add a seek button, which takes seconds and defaults to 30.
Removing a control is removing a line. That’s the trade you get for the extra setup.
Read player state
<mux-video> is a real media element, so every media event you already listen for still fires on it: play, timeupdate, ended, error, and the rest. Mux Player listeners port directly. There are a few extras: streamtypechange, targetlivewindowchange, sourcechange, and contentdatachange.
For UI state rather than media state, use PlayerController inside a custom element. Give it a selector for the feature you care about and it keeps your element in sync:
import { PlayerController, playerContext, ReactiveElement, selectTime } from '@videojs/html';
class MyElapsed extends ReactiveElement {
#time = new PlayerController(this, playerContext, selectTime);
}There are no on* props. Read state through the preset’s usePlayer hook, typed to that preset’s features:
import { usePlayer } from '@videojs/react/video';
function Elapsed() {
const currentTime = usePlayer((state) => state.currentTime);
return <span>{currentTime}</span>;
}Every feature has a selector (selectPlayback, selectTime, selectVolume, selectQuality, selectLive, selectTextTrack, and so on) for when you want a whole slice rather than one value.
Be honest with yourself about this one when you plan the work. If your app leans on onPlay, onTimeUpdate, onEnded, and onError, there’s no line-by-line port; those become hooks, or listeners you attach to a ref on the media component.
For raw media events, attach listeners to a ref. The Mux media is a real media element underneath, so play, timeupdate, ended, and error all fire on it.
Drive playback
| Mux Player | Video.js v10 |
|---|---|
player.play(), player.pause() |
media.play(), media.pause(), or the player’s togglePaused |
player.currentTime = 10 |
media.currentTime = 10 |
player.volume, player.muted |
media.volume, media.muted |
player.playbackRate |
media.playbackRate, or the player’s setPlaybackRate |
player.requestFullscreen() |
the player’s enterFullscreen, exitFullscreen, toggleFullscreen |
player.addChapters([…]) |
a <track kind="chapters">, covered below |
Anything that’s a standard media property you set on the media. Anything the browser doesn’t own — fullscreen, captions, quality — goes through the player, because the player is what tracks it.
Which Mux media should you use?
You can skip this section until you hit one of the two problems in it.
MuxVideo comes in two flavors, backed by two different playback engines, and the import path chooses:
| Import | Engine | When |
|---|---|---|
.../media/mux-video |
hls.js | The default. Start here. |
.../media/mux-video/spf |
SPF | You want a much smaller bundle and neither problem below applies. |
.../media/mux-video/hls-js |
hls.js | You want to pin hls.js, and reach its instance and settings. |
SPF is Video.js’s own playback engine, and it’s the reason v10 can be as small as it is. It doesn’t do two things hls.js does:
- TS-packaged media. Some Mux assets are packaged as TS rather than CMAF. If any of your catalog is, stay on the default.
- DRM. SPF doesn’t license protected content.
Both flavors register the same element and take the same source, so moving between them is an import change and nothing else. Don’t import both into one build; only one registration wins.
The default doesn’t promise which engine it uses, which is what leaves it free to change. Import /hls-js if your app depends on hls.js being the one. Otherwise take the default: it plays everything.
The Mux audio component follows the same three paths, and pairs with the audio player and audio skins.
Live streams and the settings menu
Live streams use a different player and skin, because live has different state and a different UI.
<live-video-player>
<live-video-skin>
<mux-video src="https://stream.mux.com/EcHgOK9coz5K….m3u8"></mux-video>
</live-video-skin>
</live-video-player>import { LiveVideoPlayer, LiveVideoSkin } from '@videojs/react/live-video';
import { MuxVideo } from '@videojs/react/media/mux-video';
<LiveVideoPlayer>
<LiveVideoSkin>
<MuxVideo source={{ playbackId: 'EcHgOK9coz5K…' }} playsInline />
</LiveVideoSkin>
</LiveVideoPlayer>You get streamType, targetLiveWindow, and liveEdgeStart in state, plus a live button that jumps to the live edge. See the live feature.
The live composition is narrower than the video one on purpose: it leaves out quality, audio-track, and playback-rate state, none of which apply cleanly to a stream with no fixed duration. So a live skin’s settings menu holds captions and nothing else. If you need one of the others on a live player, build your own player from a feature list rather than taking the preset’s.
That’s what createPlayer is for. Hand it a feature list and you get back the mixins to define your own player element.
That’s what createPlayer is for. Hand it a feature list and you get back a Player component and a usePlayer hook, the same pair every preset is built from.
The settings menu
On a video player, the settings menu appears on its own when there’s something to put in it:
| What | Component | Notes |
|---|---|---|
| Quality | Quality radio group | Lets a viewer pick a rendition. To cap the manifest instead, use source.playback.maxResolution. |
| Audio tracks | Audio track radio group | For multi-language audio. |
| Captions | Captions radio group | Rendered by the browser. |
| Speed | Playback rate radio group | The rates are a fixed set — 0.2, 0.5, 0.7, 1, 1.2, 1.5, 1.7, 2 — and you can’t choose your own yet (#1404). |
Caption styling is limited to the positioning custom properties the skins expose. There’s no equivalent of a text-track settings dialog.
Chapters and cue points
Chapters are content, not an API call. Add a chapters track inside your media and the skins segment the time slider and show the chapter title on hover:
<mux-video src="https://stream.mux.com/EcHgOK9coz5K….m3u8">
<track kind="chapters" src="/chapters.vtt" default />
</mux-video><MuxVideo source={{ playbackId: 'EcHgOK9coz5K…' }}>
<track kind="chapters" src="/chapters.vtt" default />
</MuxVideo>There’s no addChapters(). If you were building that VTT on the fly, you’ll still need to, but you point the player at it instead of passing an array (#1268).
Two gaps to plan around. The player exposes chaptersCues as a read-only array, but there’s no active-chapter state and no chapterchange event, and no menu to jump between chapters (#1873). If your app highlights the current chapter in its own UI, derive it from currentTime and chaptersCues. Cue points aren’t implemented at all (#1442).
Signed playback and DRM
Signed playback works today. Each token is a parameter in its own group, and Video.js checks each one’s audience before building a URL, so a token in the wrong slot produces no URL rather than a request Mux would reject:
{
playbackId: 'EcHgOK9coz5K…',
playback: { token: '…' }, // audience: v
poster: { token: '…' }, // audience: t
storyboard: { token: '…' }, // audience: s
}Neither player refreshes tokens. Mux Player at least notices an expired one and shows a friendly message; Video.js doesn’t surface that yet (#1432).
DRM works on the default Mux media and on native HLS. Hand it a license token and Video.js derives the FairPlay, Widevine, and PlayReady license servers from it, along with the FairPlay application certificate:
{
playbackId: 'EcHgOK9coz5K…',
playback: { token: '…' },
drm: { token: '…' }, // audience: d
}DRM playback is always signed, so drm.token needs a playback.token beside it. For content Mux doesn’t license, name license servers yourself, keyed by key system; yours win over the derived ones key by key.
The SPF flavor doesn’t license DRM (#1776), which is one of the two reasons to stay on the default import.
Escape hatches
| Mux Player | Video.js v10 |
|---|---|
media.nativeEl |
.target on the element |
| the hls.js instance | .host.engine on the hls.js-backed flavor |
prefer-playback |
source.preferPlayback, or pick native HLS outright |
| Mux Player | Video.js v10 |
|---|---|
media.nativeEl |
a ref on the media component, then .target |
| the hls.js instance | .host.engine through that same ref, on the hls.js-backed flavor |
prefer-playback |
source.preferPlayback, or pick native HLS outright |
source.preferPlayback is the closest mapping: set it to 'native' and the Mux media hands playback to the browser’s own HLS support instead of building an MSE pipeline, which is what prefer-playback="native" did.
Program Date Time has no convenience surface: no getStartDate(), no currentPdt. The player exposes liveEdgeStart and targetLiveWindow; for PDT itself, reach into the engine.
Known gaps
Ordered roughly by how many migrations they’ll touch.
- No skin draws
content-title, so setting it alone won’t put a title on screen (#1123). - Playback rates are a fixed set —
0.2,0.5,0.7,1,1.2,1.5,1.7,2— and you can’t choose your own yet (#1404). - Two skins, against Mux Player’s five themes, and no runtime theme switch. That’s a deliberate trade, fewer looks each of which you can eject, but it’s a real difference if you shipped
theme="classic". - Chapters render on the time slider, but there’s no active-chapter state, no
chapterchangeevent, and no chapter menu (#1873). NoaddChapters()either (#1268). - Cue points aren’t implemented (#1442).
- No
playback-idattribute; use asrcURL or thesourceobject. - Lazy loading (
loading="viewport|page") has no equivalent. - The SPF Mux media doesn’t license DRM and doesn’t play TS-packaged media (#1776). The default flavor does both.
- Signed playback works, but there’s no expired-token message and no auto-refresh (#1432).
- Neither packaged video skin includes skip buttons; add a seek button.
- The controls auto-hide delay isn’t configurable, and there’s no disabled state for the controls (#1728).
- Nothing persists between sessions: volume, captions language, speed, quality. That’s out of scope for GA (#944). Default subtitle language is tracked at #1423, though Mux users can set
source.playback.defaultSubtitlesLangand let the manifest decide. - Caption styling is limited to the skins’ positioning custom properties.
- Smaller conveniences without homes yet: debug mode (#1406) and autoplay with a muted fallback (#1039). Unmuting from zero volume already restores a sensible level, so Mux Player’s smart-unmute behavior carries over.
See also
- Mux Video and Mux Audio
- Mux Data
- Skins and Customize skins