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: { maxBufferLength: 60 },
}}
/>const video = document.querySelector('hlsjs-video');
video.source = {
src: 'https://example.com/stream.m3u8',
preferPlayback: 'mse',
engine: { 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, alongside engine |
preferPlayback |
| How the engine behaves | source.engine |
hls.js’s maxBufferLength |
| 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 engine, 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: { 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: { maxBufferLength: 60 } };
video.source = { src, engine: { maxBufferLength: 60 } }; // no-opOnly a change to engine, preferPlayback, or the resolved content type recreates the playback engine.
Engine options
engine is the playback engine’s own configuration object, handed over untouched. There’s no Video.js wrapper around it, so whatever the engine documents works:
| Element | engine is |
|---|---|
| HlsJsVideo, MuxVideo, MuxAudio | an hls.js config |
| DashVideo | dash.js settings |
VimeoVideo |
Vimeo embed parameters |
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: { maxBufferLength: 60, enableWorker: false },
}}
/>video.source = {
src: 'https://example.com/stream.m3u8',
engine: { maxBufferLength: 60, enableWorker: false },
};dash.js takes settings on a running player, so engine is applied in place and playback continues uninterrupted:
<DashVideo
source={{
src: 'https://example.com/manifest.mpd',
engine: { streaming: { abr: { maxBitrate: { video: 2000 } } } },
}}
/>const video = document.querySelector('dash-video');
video.source = {
src: 'https://example.com/manifest.mpd',
engine: { streaming: { abr: { maxBitrate: { video: 2000 } } } },
};Because engine 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 engine.
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
DRM is engine configuration like any other, so it goes under engine — for hls.js, that’s emeEnabled and drmSystems, keyed by EME key system id:
<HlsJsVideo
source={{
src: 'https://example.com/protected.m3u8',
engine: {
emeEnabled: true,
drmSystems: {
'com.widevine.alpha': { licenseUrl: 'https://license.example.com/widevine' },
'com.apple.fps': {
licenseUrl: 'https://license.example.com/fairplay',
serverCertificateUrl: 'https://license.example.com/fairplay-cert',
},
},
},
}}
/>video.source = {
src: 'https://example.com/protected.m3u8',
engine: {
emeEnabled: true,
drmSystems: {
'com.widevine.alpha': { licenseUrl: 'https://license.example.com/widevine' },
'com.apple.fps': {
licenseUrl: 'https://license.example.com/fairplay',
serverCertificateUrl: 'https://license.example.com/fairplay-cert',
},
},
},
};Name every system you hold a license server for — which one gets used is the browser’s choice. serverCertificateUrl is the server (application) certificate FairPlay requires; Widevine and PlayReady ignore it.
Only the hls.js (MSE) engine plays DRM-protected media, so native HLS playback ignores all of this and warns in development.
Video.js adds one thing on top: for Widevine it 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.
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 included:
<MuxVideo
source={{
playbackId: 'BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM',
playback: { maxResolution: '1080p' },
engine: { maxBufferLength: 60 },
}}
playsInline
/>const video = document.querySelector('mux-video');
video.source = {
playbackId: 'BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM',
playback: { maxResolution: '1080p' },
engine: { 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 and nothing else. It fills in the engine.drmSystems above for you, EME included:
<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. Naming engine.drmSystems yourself takes precedence, for content Mux doesn’t license.
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
Two elements take src and the usual media attributes, but expose no source:
- NativeHlsVideo hands playback to the browser, so there is no engine to configure.
- SimpleHlsVideo and SimpleHlsAudioOnly 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 |
config.muxData |
the Mux Data component’s own props |
config.googleCast |
the Google Cast component’s own props |
config on VimeoVideo |
source.engine |
The hlsJs and dashJs nesting is gone: engine is the engine’s config object now, so its options sit one level shallower than they did under config.
// Before
<HlsJsVideo src={src} config={{ preferPlayback: 'native', hlsJs: { maxBufferLength: 60 } }} />
// After
<HlsJsVideo source={{ src, preferPlayback: 'native', engine: { maxBufferLength: 60 } }} />// Before
video.config = { preferPlayback: 'native', hlsJs: { maxBufferLength: 60 } };
// After
video.source = { src, preferPlayback: 'native', engine: { maxBufferLength: 60 } };<native-hls-video>, <simple-hls-video>, and <simple-hls-audio-only> accepted a config that nothing read. Remove it — those elements never applied it.