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).
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:
-
limitRenditionByPlayerDimensions(defaulttrue) 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 tofalse. -
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). -
Gating on declared peak
BANDWIDTH× 1.2. DASHbandwidthis max-over-window; HLS often omitsAVERAGE-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:
- 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 → weight0.7(react to congestion within one segment); above → weight0.45(climb steadily, no flip-flop). - Targets: for each enabled representation, compare against
AVERAGE-BANDWIDTHwhen the manifest provides it, elsepeak × 0.75as a proxy for the real average. - Raw pick: highest rendition whose target is below the estimate; otherwise the lowest.
- 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.
| Constant | Value | Raise it to… |
|---|---|---|
PEAK_TO_AVERAGE_RATIO | 0.75 | lower → reach the top rung sooner |
EWMA_WEIGHT_DOWN | 0.7 | drop faster on congestion |
EWMA_WEIGHT_UP | 0.45 | recover faster, but more oscillation |
HEALTHY_BUFFER_SECONDS | 10 | lower → buffer-optimism engages sooner |
HEALTHY_BUFFER_ESTIMATE_BONUS | 1.4 | more 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:
peakBitrateis the manifest'sBANDWIDTH, 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 trapPEAK_TO_AVERAGE_RATIOabove exists to work around inside the selector.)measuredBandwidthis 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.contentBitrateis 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_VARIANCEis globally set to1.0for 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 onsetSource(), since each source gets a fresh engine handler. Measure a window by diffing two snapshots rather than reading one.secondsLoadedis 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.bytesTransferredandtransferDurationdeliberately cover every loader, since that is the real cost of delivery.
The full rationale, rejected alternatives, and commit history are in ADR-0001.