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:

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, maxAutoResolution, capRenditionToPlayerSize
How a specific engine behaves source.engine, under that engine’s name hls.js’s maxBufferLength, in source.engine.hlsJs
An extension’s own settings that extension’s props Mux Data, GoogleCast

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:

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:

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:

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:

video.source = { src, engine: { hlsJs: { maxBufferLength: 60 } } };
video.source = { src, engine: { hlsJs: { maxBufferLength: 60 } } }; // no-op

Only 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
engine.cloudflare CloudflareVideo Stream player parameters
engine.spotify SpotifyAudio Spotify embed options
engine.tiktok TikTokVideo TikTok player parameters
engine.twitch TwitchVideo Twitch embed 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:

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:

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:

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.

maxAutoResolution caps the highest rendition adaptive bitrate selection reaches for on its own:

video.source = { src: 'https://example.com/stream.m3u8', maxAutoResolution: '720p' };

The cap limits automatic selection without hiding anything. Renditions above it stay in videoRenditions, so a viewer can still choose 1080p by hand. Reach for it to hold down bandwidth on a stream that would otherwise climb to 4K, or to keep a background or thumbnail player cheap.

Renditions are matched on pixel area rather than height, which keeps unusual aspect ratios honest: an ultrawide 2560×1080 rendition carries more pixels than 16:9 1080p, so a '1080p' cap leaves it out. When every rendition sits above the cap, the smallest one plays.

Changing the cap applies to the running engine, so playback continues uninterrupted. It needs the hls.js engine — native HLS playback ignores it, and audio-only streams have no video renditions to cap.

Capping to the player’s size

A 400px-wide player has no use for a 4K rendition, so capRenditionToPlayerSize holds automatic selection to the smallest rendition that still covers the element. It defaults to true, and the cap follows the element as it’s resized:

video.source = { src: 'https://example.com/stream.m3u8', capRenditionToPlayerSize: false };

The element is measured in device pixels, so a 2 device pixel ratio asks for twice the rendition a CSS measurement would — a 640px-wide player on a retina screen still gets 720p. Switch the cap off for a player whose layout size understates what it needs, such as one that goes fullscreen without a resize.

minAutoResolution bounds how far down that cap can reach, and defaults to '720p':

video.source = { src: 'https://example.com/stream.m3u8', minAutoResolution: '480p' };

It is not a quality floor. It bounds the size-derived cap and nothing else. When bandwidth is poor, adaptive selection still drops below it — the cap is a ceiling, and selection stays free underneath. It also never raises an explicit maxAutoResolution: ask for at most '360p' alongside a '720p' floor and you get '360p'.

The default is there because the low rungs of a ladder exist for poor network conditions, and capping a small player down to them looks worse than its size suggests. Name a lower rung to weaken the floor, or '270p' to lift it for any real ladder.

Four things that sound alike

Four separate mechanisms bound which rendition plays, and they’re easy to mistake for each other:

Option Side What it does
source.playback.maxResolution Server A Mux query param. Trims the manifest, so higher renditions never arrive — not even by hand.
source.maxAutoResolution Client A ceiling on automatic selection. Everything still arrives and stays selectable by hand.
source.capRenditionToPlayerSize Client The same kind of ceiling, derived from the element’s rendered size and moving with it.
source.minAutoResolution Client A bound on the one above. Not a quality minimum, and not related to playback.minResolution.

playback.maxResolution and playback.minResolution are the server-side pair: they decide what the manifest carries. The other three only bound what adaptive selection picks from whatever does arrive.

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:

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:

const video = document.querySelector('mux-video');

video.source = {
  playbackId: 'BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM',
  playback: { maxResolution: '1080p' },
  engine: { hlsJs: { maxBufferLength: 60 } },
};

playback params ride along in the URL, so playback.maxResolution decides which renditions the manifest carries in the first place. maxAutoResolution is the other half of that pair: it caps what adaptive selection picks from the renditions that do arrive. Use playback.maxResolution when a viewer should never get 4K, and maxAutoResolution when they should be able to ask for it.

The same split applies at the bottom of the ladder, and the names are close enough to be worth restating: playback.minResolution asks Mux to leave the lowest renditions out of the manifest, while minAutoResolution is a client-side bound on the player-size cap. They are unrelated.

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:

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:

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 ?? '';

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:

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

HlsVideo and HlsAudio 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 extension’s own props
config.googleCast the GoogleCast extension’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
video.config = { preferPlayback: 'native', hlsJs: { maxBufferLength: 60 } };

// After
video.source = { src, preferPlayback: 'native', engine: { hlsJs: { maxBufferLength: 60 } } };

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

See also