Skip to content
FrameworkStyle

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

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

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

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

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.

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

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

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

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

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

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

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

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

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:

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

Elements without engine options

Two elements take src and the usual media attributes, but expose no source:

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

<native-hls-video>, <simple-hls-video>, and <simple-hls-audio-only> accepted a config that nothing read. Remove it — those elements never applied it.

See also