Media sources
Set what a media element plays and how its engine plays it with the structured source property
Every media element takes a src. Elements that drive a playback engine also take source, a structured object that carries the URL alongside everything else about that source:
<HlsJsVideo
source={{
src: 'https://example.com/stream.m3u8',
preferPlayback: 'mse',
engine: { hlsJs: { maxBufferLength: 60 } },
}}
/>const video = document.querySelector('hlsjs-video');
video.source = {
src: 'https://example.com/stream.m3u8',
preferPlayback: 'mse',
engine: { hlsJs: { maxBufferLength: 60 } },
};Three tiers, three homes
The shape answers one question: does this option describe the source, or how to play it?
| Tier | Home | Example |
|---|---|---|
| Which source to play | source.src, or an element’s own identity fields |
src, MuxVideo’s playbackId |
| How to interpret it | source.type |
'video/mp4' |
| How Video.js plays it | source, at the top level |
preferPlayback |
| How a specific engine behaves | source.engine, under that engine’s name |
hls.js’s maxBufferLength, in source.engine.hlsJs |
| A side-car component’s own settings | that component’s props | Mux Data, Google Cast |
type is worth reaching for when the URL lies about its contents. Video.js infers the content type from the file extension, so a manifest served from an extensionless or signed URL may need to say so explicitly:
<HlsJsVideo source={{ src: 'https://example.com/asset?id=42', type: 'application/vnd.apple.mpegurl' }} />video.source = { src: 'https://example.com/asset?id=42', type: 'application/vnd.apple.mpegurl' };src and source stay in sync
They are two views of the same thing, and writing either updates the other. Setting source derives src. Setting src replaces only the identity half and keeps the rest, such as type and the engine options, intact:
<HlsJsVideo source={{ src: 'https://example.com/a.m3u8', preferPlayback: 'native' }} />video.source = { src: 'https://example.com/a.m3u8', preferPlayback: 'native' };
video.src = 'https://example.com/b.m3u8';
// playback options survive the URL change
video.source; // { src: 'https://example.com/b.m3u8', preferPlayback: 'native' }A sourcechange event fires whenever source changes, from either direction.
Assigning source replaces it
source is not merged. A new object is a fresh start, and keys you leave out are dropped:
<HlsJsVideo source={{ src, type: 'video/mp4', preferPlayback: 'native' }} />
// later: type and preferPlayback are gone, because the new object omits them
<HlsJsVideo source={{ src }} />video.source = { src, type: 'video/mp4', preferPlayback: 'native' };
// type and preferPlayback are gone, because the new object omits them
video.source = { src };Equivalent sources cost nothing
Sources are compared structurally, not by reference. Reassigning an object with the same values is a no-op — no reload, and no engine teardown:
// A fresh object literal on every render. Nothing reloads.
<HlsJsVideo source={{ src, engine: { hlsJs: { maxBufferLength: 60 } } }} />This is why you can write source inline. React hands the element a brand new object every render, and the element recognizes it as the same source. There’s no need to memoize it or spread the previous value to avoid clobbering anything.
video.source = { src, engine: { hlsJs: { maxBufferLength: 60 } } };
video.source = { src, engine: { hlsJs: { maxBufferLength: 60 } } }; // no-opOnly a change to the engine options, preferPlayback, or the resolved content type recreates the playback engine.
Engine options
Engine options live under source.engine, namespaced by engine. Each key there holds that engine’s own configuration object, handed over untouched — there’s no Video.js wrapper around it, so whatever the engine documents works:
| Key | Elements that read it | It holds |
|---|---|---|
engine.hlsJs |
HlsJsVideo, MuxVideo, MuxAudio | an hls.js config |
engine.nativeHls |
NativeHlsVideo, and the three above whenever the browser plays the manifest | options for the browser’s own HLS support (today, DRM) |
engine.dashJs |
DashVideo | dash.js settings |
engine.vimeo |
VimeoVideo |
Vimeo embed parameters |
engine.youtube |
YouTubeVideo |
YouTube player parameters |
Naming the engine rather than using one generic key matters where an element has more than one to choose from. HlsJsVideo plays through hls.js or through the browser depending on the platform and preferPlayback, and only one of them runs — so a single source can describe both paths without either engine reading the other’s options.
hls.js reads its options when the engine is constructed, so changing them tears down the engine and builds a new one:
<HlsJsVideo
source={{
src: 'https://example.com/stream.m3u8',
engine: { hlsJs: { maxBufferLength: 60, enableWorker: false } },
}}
/>video.source = {
src: 'https://example.com/stream.m3u8',
engine: { hlsJs: { maxBufferLength: 60, enableWorker: false } },
};dash.js takes settings on a running player, so engine.dashJs is applied in place and playback continues uninterrupted:
<DashVideo
source={{
src: 'https://example.com/manifest.mpd',
engine: { dashJs: { streaming: { abr: { maxBitrate: { video: 2000 } } } } },
}}
/>const video = document.querySelector('dash-video');
video.source = {
src: 'https://example.com/manifest.mpd',
engine: { dashJs: { streaming: { abr: { maxBitrate: { video: 2000 } } } } },
};Because engine.dashJs replaces rather than merges, dropping a key restores the dash.js default instead of leaving the old value behind.
Options Video.js normalizes
Where an option means the same thing across engines, it sits on source itself rather than inside an engine’s namespace.
preferPlayback picks between hls.js and the browser’s own HLS support:
<HlsJsVideo source={{ src: 'https://example.com/stream.m3u8', preferPlayback: 'native' }} />video.source = { src: 'https://example.com/stream.m3u8', preferPlayback: 'native' };It’s a preference, not a demand — Video.js falls back to whichever path can actually play the source. HlsJsVideo, MuxVideo, and MuxAudio accept it; DASH and Vimeo have no second playback path.
DRM protected sources
Protected content is licensed through source.drm, keyed by EME key system id — alongside the URL rather than inside an engine’s namespace, because which engine plays the manifest is decided later and both paths read it:
<HlsJsVideo
source={{
src: 'https://example.com/protected.m3u8',
drm: {
'com.apple.fps': {
licenseUrl: 'https://license.example.com/fairplay',
serverCertificateUrl: 'https://license.example.com/fairplay-cert',
},
'com.widevine.alpha': { licenseUrl: 'https://license.example.com/widevine' },
'com.microsoft.playready': { licenseUrl: 'https://license.example.com/playready' },
},
}}
/>video.source = {
src: 'https://example.com/protected.m3u8',
drm: {
'com.apple.fps': {
licenseUrl: 'https://license.example.com/fairplay',
serverCertificateUrl: 'https://license.example.com/fairplay-cert',
},
'com.widevine.alpha': { licenseUrl: 'https://license.example.com/widevine' },
'com.microsoft.playready': { licenseUrl: 'https://license.example.com/playready' },
},
};Name every system you hold a license server for — which one gets used is the browser’s choice. serverCertificateUrl is the DRM server (application) certificate FairPlay requires; Widevine and PlayReady ignore it.
What each path can license
The same drm reaches both playback paths, and how far it gets is the one thing worth knowing about each:
| Path | Key systems it negotiates | What Video.js does with drm |
|---|---|---|
| hls.js (MSE) | FairPlay, Widevine, PlayReady | Hands it to hls.js as drmSystems, with emeEnabled switched on |
| The browser’s own HLS | FairPlay | Answers the element’s key requests itself: POSTs the CDM’s license request to licenseUrl and hands the response back |
So a source naming only Widevine plays through hls.js and cannot play on the native path — NativeHlsVideo, or any HLS element the browser ends up playing the manifest for. Video.js says so in development, and encrypted media that gets there with no com.apple.fps license server fails with a MEDIA_ERR_ENCRYPTED error rather than hanging.
For Widevine on hls.js, Video.js asks for a hardware-backed CDM first, falling back to whatever robustness the browser offers, so content restricted to L1 devices plays where it can. Supplying your own requestMediaKeySystemAccessFunc replaces that entirely.
Licensing one engine differently
An engine’s own drmSystems replaces source.drm, for that engine alone. Reach for it when one path licenses differently, or when you need more of hls.js’s DRM configuration than drm covers:
video.source = {
src: 'https://example.com/protected.m3u8',
drm: { 'com.apple.fps': { licenseUrl, serverCertificateUrl } },
engine: {
// hls.js licenses from here instead, and the native path still from `drm`.
hlsJs: { drmSystems: { 'com.widevine.alpha': { licenseUrl: widevineLicenseUrl } } },
},
};It replaces rather than merges, so the FairPlay server above is gone as far as hls.js is concerned. engine.nativeHls.drmSystems does the same for the native path.
Mux sources name a playback ID
MuxVideo and MuxAudio identify a source by playbackId rather than a URL, and derive src from it. Everything else works the same, engine options included:
<MuxVideo
source={{
playbackId: 'BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM',
playback: { maxResolution: '1080p' },
engine: { hlsJs: { maxBufferLength: 60 } },
}}
playsInline
/>const video = document.querySelector('mux-video');
video.source = {
playbackId: 'BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM',
playback: { maxResolution: '1080p' },
engine: { hlsJs: { maxBufferLength: 60 } },
};Setting a Mux stream URL as src works too — the element parses the playback ID and query params back out into source. To play something Mux doesn’t host, name it with src inside source; engine options still apply.
Two more source params describe images rather than playback. source.storyboard and source.poster carry the modifiers for the storyboard VTT and the poster still — both belong to the source, since a signed token scopes them to one playback ID:
<MuxVideo source={{ playbackId, storyboard: { format: 'jpg' }, poster: { time: 12 } }} playsInline />video.source = { playbackId, storyboard: { format: 'jpg' }, poster: { time: 12 } };MuxVideo uses the storyboard itself, adding the thumbnail <track> for you so hover previews work without extra markup. Live streams have no storyboard, so the track is dropped once the stream type is known, and signed playback without a matching storyboard token adds none.
Mux signs DRM with a token
Mux serves FairPlay, Widevine, and PlayReady from URLs derived from a single license token, so source.drm takes that token in place of the license servers it would otherwise name. Mux fills the rest in for you, so protected media plays whichever path the browser takes:
<MuxVideo
source={{
playbackId,
playback: { token: playbackToken },
drm: { token: drmToken },
}}
playsInline
/>video.source = {
playbackId,
playback: { token: playbackToken },
drm: { token: drmToken },
};DRM playback is always signed, so a playback.token belongs alongside it — and poster.token / storyboard.token for the images. Each is scoped to a different audience, so they are four separate tokens rather than one reused four times. Sign them on your server; see Mux’s DRM guide for how.
A drm.token that isn’t scoped to DRM is ignored rather than sent, since the license request would be rejected. License servers named alongside the token win, key by key, for the systems Mux doesn’t license:
video.source = {
playbackId,
playback: { token: playbackToken },
// FairPlay and PlayReady from Mux, Widevine from your own server.
drm: { token: drmToken, 'com.widevine.alpha': { licenseUrl: widevineLicenseUrl } },
};source.poster gets no such treatment — it’s only data, and nothing applies it to the media. Both URLs are readable from contentData, keyed by what each one describes:
video.contentData;
// {
// poster: 'https://image.mux.com/PLAYBACK_ID/thumbnail.webp?time=12',
// storyboard: 'https://image.mux.com/PLAYBACK_ID/storyboard.vtt?format=jpg',
// }
// Use the still as the poster, if that's what you want it for.
video.poster = video.contentData.poster ?? '';media.contentData;
// {
// poster: 'https://image.mux.com/PLAYBACK_ID/thumbnail.webp?time=12',
// storyboard: 'https://image.mux.com/PLAYBACK_ID/storyboard.vtt?format=jpg',
// }It’s read-only and derived from source, so read it again after sourcechange. A key is missing when its URL can’t be built: no playback ID, or signed playback with no matching image token. Changing the URLs means changing the params they’re built from.
source.poster takes the full set of Mux image modifiers, so a narrower still for a small viewport is a width:
<MuxVideo source={{ playbackId, poster: { width: 320 } }} playsInline />video.source = { ...video.source, poster: { width: 320 } };Because source has no attribute, the poster frame has one of its own. poster-time reflects to source.poster.time, letting you set it from markup:
<mux-video src="https://stream.mux.com/PLAYBACK_ID.m3u8" poster-time="12"></mux-video>It survives a src change, so swapping the source keeps the frame you asked for.
Elements without engine options
SimpleHlsVideo and SimpleHlsAudioOnly take src and the usual media attributes but expose no source. They run on our own playback engine, which does not accept configuration from the element yet.
Migrate from config
Media elements used to take a config object: one untyped bag holding engine options, source overrides, and component settings at once. It’s gone, and each of its keys now has a specific home:
| Before | After |
|---|---|
config.preferPlayback |
source.preferPlayback |
config.contentType |
source.type |
config.hlsJs |
source.engine.hlsJs |
config.dashJs |
source.engine.dashJs |
config.muxData |
the Mux Data component’s own props |
config.googleCast |
the Google Cast component’s own props |
config on VimeoVideo |
source.engine.vimeo |
The engine keys keep their names, so their contents move across unchanged — what moves is the object they sit in.
// Before
<HlsJsVideo src={src} config={{ preferPlayback: 'native', hlsJs: { maxBufferLength: 60 } }} />
// After
<HlsJsVideo source={{ src, preferPlayback: 'native', engine: { hlsJs: { maxBufferLength: 60 } } }} />// Before
video.config = { preferPlayback: 'native', hlsJs: { maxBufferLength: 60 } };
// After
video.source = { src, preferPlayback: 'native', engine: { hlsJs: { maxBufferLength: 60 } } };<simple-hls-video> and <simple-hls-audio-only> accepted a config that nothing read. Remove it — those elements never applied it.