Skip to main content

Quality & adaptive bitrate

On an HLS/DASH source, @videojs/http-streaming ("VHS") exposes a rendition ladder. In Auto mode the player picks, before every segment, the highest rendition the connection can currently sustain. Manual mode pins one rendition.

player.getQualityLevels(); // [{ id, label: 'Auto', isAuto: true, selected: true }, { label: '1080p', … }, …]
player.setQualityLevel(id); // lock to one rendition, disabling ABR
player.setAutoQuality(); // restore adaptive bitrate
player.on('qualitychange', ({ level }) => console.log(level.label));
player.getQualityLevels().find((l) => l.selected); // the current level (Auto until you pin one)

Exactly one level is selected whenever the list is non-empty: the Auto entry while adaptive switching is on, otherwise the rendition passed to setQualityLevel(). Loading a new source resets the selection to Auto.

The control-bar quality section auto-hides when fewer than two levels are available, and does nothing for progressive MP4/MP3 (there is only ever one implicit quality).

Safari / iOS

HLS plays through Safari's native engine, which exposes no rendition list: getQualityLevels() returns [] and the quality section never shows for HLS there. Native ABR is also size-gated: Safari picks the rendition matching the player's rendered pixel size (CSS size × devicePixelRatio), so a small player stays on a low rendition no matter the bandwidth. Size the player to the quality you want; there is no web API to override this. DASH on Safari is MSE-driven and keeps the full ladder.

Why the selector is custom​

The reference is the "Auto" behaviour of mature commercial players: on the same stream and line, it reaches the top rendition and holds it, stepping down only on real congestion. VHS's default selector sat one or two rungs below that, or froze outright, for three structural reasons:

  1. limitRenditionByPlayerDimensions (default true) caps Auto to the smallest rendition covering the player's rendered CSS pixel size, regardless of bandwidth. A ~100 Mbps stream stayed on 720p in Auto, jumped to 1080p on a manual pick, and dropped back the instant Auto resumed. → set to false.

  2. VHS compares against systemBandwidth, a harmonic mean of the network estimate and the decrypt/transmux/append rate: 1 / (1/bandwidth + 1/throughput). It is always below the real link speed and, with JS transmux under load, can fall below a mid-rendition's bitrate (freezing Auto at the bottom on a connection that is easily fast enough).

  3. Gating on declared peak BANDWIDTH × 1.2. DASH bandwidth is max-over-window; HLS often omits AVERAGE-BANDWIDTH; QVBR encodes declare peak 20–40 % above the true average. The gate then demands far more headroom than the rendition actually needs.

There is also a Network Information API detour: with useNetworkInformationApi (default true), the vhs.bandwidth getter returns navigator.connection.downlink * 1e6 instead of the measured value whenever downlink reads < 10 Mbps (a coarse, bucketed guess that saturates at 10). This only bites Chromium, which is why "Auto" looked fine in one session and broken in another. Rather than toggle the flag (tried and reverted upstream in our history), the custom selector simply stops depending on that getter.

The custom selector​

eveAbrPlaylistSelector replaces vhs.selectPlaylist at runtime, re-attached on every loadstart (each new source gets a fresh VHS handler). Per call:

  1. Estimate: read the raw, segment-measured throughput from mainSegmentLoader_.bandwidth (pre-systemBandwidth, pre-NetInfo). Fold it into a per-handler EWMA with an asymmetric weight: newest sample below the average → weight 0.7 (react to congestion within one segment); above → weight 0.45 (climb steadily, no flip-flop).
  2. Targets: for each enabled representation, compare against AVERAGE-BANDWIDTH when the manifest provides it, else peak × 0.75 as a proxy for the real average.
  3. Raw pick: highest rendition whose target is below the estimate; otherwise the lowest.
  4. Buffer-gated optimism: with ≥ 10 s forward buffer, recompute with estimate × 1.4, but adopt the higher pick only if it exceeds the raw pick and the raw pick is not itself a downswitch. So the bonus can only push up, and a real bandwidth drop is never masked.

No player-dimension filtering.

Tunables​

All five knobs are named constants at the top of the ABR block in the source.

ConstantValueRaise it to…
PEAK_TO_AVERAGE_RATIO0.75lower → reach the top rung sooner
EWMA_WEIGHT_DOWN0.7drop faster on congestion
EWMA_WEIGHT_UP0.45recover faster, but more oscillation
HEALTHY_BUFFER_SECONDS10lower → buffer-optimism engages sooner
HEALTHY_BUFFER_ESTIMATE_BONUS1.4more aggressive climb when the buffer is healthy

Observing what ABR actually does​

Two APIs report on adaptive switching, and they answer different questions.

qualitychange fires when the selection changes: someone called setQualityLevel() or setAutoQuality(). It says nothing about automatic switching, because in Auto mode the selection stays Auto no matter how often the renditions change underneath.

renditionchange fires when the rendition actually being played changes, whichever chose it. It carries the level it switched away from, so the direction is derivable:

player.on('renditionchange', ({ level, previous }) => {
if (previous && level.bitrate < previous.bitrate) {
// The selector stepped down: the connection is under pressure.
}
});

A step down is the most trustworthy congestion signal available, because it is the selector's own verdict after watching real segment downloads, rather than a number derived from one sample.

For the measurements behind that verdict, call getPlaybackStats():

const stats = player.getPlaybackStats();
// Seconds of media fetched per second spent downloading. Above 1x the player
// is gaining on playback, which is all it needs to do.
console.log(stats?.fetchRate);

Judge a connection on fetchRate, and be wary of the alternatives:

  • peakBitrate is the manifest's BANDWIDTH, which HLS defines as the peak segment bitrate. It routinely sits well above the real average, so comparing throughput against it invents a shortfall on a healthy stream. (This is the same trap PEAK_TO_AVERAGE_RATIO above exists to work around inside the selector.)
  • measuredBandwidth is the engine's estimate from the last segment only, with time-to-first-byte folded in. Once the buffer is full the player stops asking for data it does not need, so this figure drifts down towards the stream's own bitrate on a connection of any speed.
  • contentBitrate is measured from bytes actually delivered, and is the honest denominator if you do want a ratio.

getPlaybackStats() returns undefined where there is no engine to ask, which means Safari playing HLS natively and progressive .mp4 sources.

Known limits​

  • Decision granularity is one segment (~6 s). The VHS hook cannot switch mid-segment, so recovery after a throttle lift is ~1 segment behind (mature commercial players have the same floor).
  • Vhs.BANDWIDTH_VARIANCE is globally set to 1.0 for VHS's own fallback/initial paths; the custom selector does not use it. It is a page-level static shared by every VHS player on the page.
  • The EWMA state lives on the VHS handler, so it resets per source and is not persisted across setSource().
  • getPlaybackStats() counters are cumulative for the current source and restart from zero on setSource(), since each source gets a fresh engine handler. Measure a window by diffing two snapshots rather than reading one.
  • secondsLoaded is corrected for demuxed audio (a separate audio rendition is fetched by its own loader, and the engine's raw counter adds both up), so it reports media once. bytesTransferred and transferDuration deliberately cover every loader, since that is the real cost of delivery.

The full rationale, rejected alternatives, and commit history are in ADR-0001.