Skip to the content.

video.js + VHS Knowledge Base

Everything learned while migrating the Twitch DVR extension from a custom MSE player to video.js v8.23.9 + @videojs/http-streaming (VHS) v3.17.5. This is the reference for the video.js player; the legacy custom MSE pipeline has been deleted.

Official docs:

Versions

Loading Strategy (Chrome extension)

Do NOT bundle video.js into the content script via import. esbuild bundle: true would pull the entire ~580KB library into static/index.js. Instead:

  1. Load video.min.js (+ plugins/quality-levels.min.js, plugins/hls-quality-selector.min.js) as separate content_scripts JS entries in manifest.json, before index.js.
  2. Load video-js.min.css via the manifest css array.
  3. Consume video.js as a runtime global (videojs(...)). The global is declared in src/content/videojs.d.ts, which uses import type { Player } from "video.js" (type-only — esbuild strips it, zero bundle cost) for the real player class.
"content_scripts": [{
  "matches": ["https://www.twitch.tv/*"],
  "js": ["video.min.js", "plugins/quality-levels.min.js", "plugins/hls-quality-selector.min.js", "index.js"],
  "css": ["video-js.min.css"],
  "run_at": "document_idle"
}]

Player Creation

const player = videojs("vjs-player", {
  autoplay: true,
  muted: false,          // user-installed extension → unmuted autoplay permitted
  controls: true,
  fill: true,            // NOT fluid (see Sizing)
  liveui: true,          // DVR seek bar + seek-to-live button
  html5: {
    vhs: {
      overrideNative: true,            // VHS over Chrome 141+ native HLS
      withCredentials: false,
      useBandwidthFromLocalStorage: true,
    },
  },
  liveTracker: { trackingThreshold: 10, liveTolerance: 15 },
  techOrder: ["html5"],
});
player.ready(() => {
  player.hlsQualitySelector({ displayCurrentQuality: true });
  player.src({ src: hlsUrl, type: "application/x-mpegURL" });
});

The <video> element must have class="video-js" (video.js uses it to identify the element to enhance):

<div id="vjs-player-container">
  <video id="vjs-player" class="video-js vjs-default-skin" controls playsinline preload="auto"></video>
</div>

Sizing: fill, not fluid ⚠️ CRITICAL

Use fill: true when the player’s container is already sized (our case: .video-player__container has a fixed height).

Do NOT use fluid: true with a fixed-height container. Per the layout guide, fluid uses a padding-top aspect-ratio trick with height:0 !important and recreates height via padding — it only computes correctly once the video’s intrinsic size loads. Combined with a fixed-height container this produced a 0-height / overflowing video element (the “no video” bug). The guide is explicit: “Enabling fluid mode will disable fill mode.”

Do not set inline width/height:100% on the <video> — fill manages sizing.

If you need a specific aspect ratio instead, pass aspectRatio: '16:9' or call player.aspectRatio('16:9').

CSS is Required ⚠️ CRITICAL

video-js.min.css MUST be loaded (via the manifest css array). Without it, video.js still injects its control bar / seek bar / quality menu DOM, but they render unstyled and detached — the controls float in the bottom-left corner, not attached to the player, and are “borked.” This was a real bug: the manifest only had JS entries.

Live Detection & Live UI

video.js identifies a live stream via a durationchange event where duration === Infinity. Confirmed in our logs: loadedmetadata — duration: Infinity seekable ranges: 1. This activates the vjs-live + vjs-liveui classes and the LiveTracker.

liveui: true enables the new live UI (opt-in): shows the ProgressControl (seek bar) so the user can seek within the live DVR window, hides the old LiveDisplay, and adds a SeekToLive button. Without liveui, the old UI hides the seek bar and only shows a “Live” indicator.

LiveTracker (player.liveTracker) provides: isLive(), atLiveEdge(), behindLiveEdge(), seekableStart(), seekableEnd(), liveWindow(), liveCurrentTime(), seekToLiveEdge(). Disable with liveTracker: false if not needed.

Android note (from the guide): the new live UI doesn’t work on Android due to native live HLS not supporting seekable ranges — override native HLS with VHS (overrideNative: true), which we do.

overrideNative: true ⚠️

Chrome 141+ has native HLS support. If native playback is used, VHS is bypassed and beforeRequest/onRequest hooks don’t fire (native playback doesn’t go through VHS’s XHR). overrideNative: true forces VHS to handle playback so our hooks work. Required for any auth/logging that needs to intercept HLS requests.

VHS Hooks (auth + logging)

VHS exposes videojs.Vhs.xhr.onRequest / onResponse (v8 API — NOT beforeRequest, which is v7).

videojs.Vhs.xhr.onRequest((options) => {
  // options.uri, options.headers — mutate and return options, or return void
  if (options.uri.includes(".m3u8")) dvrlog.info("VHS-REQ", options.uri.slice(0, 120));
});
videojs.Vhs.xhr.onResponse((error, response) => {
  // VHS passes the XHR object as the FIRST arg for ALL responses (not just errors).
  // A real error has no .status. Distinguish:
  const xhrLike = (error ?? response) as { status?: number; message?: string; url?: string } | null;
  const status = xhrLike?.status ?? 0;
  if (status === 0) dvrlog.warn("VHS-ERR", xhrLike?.message ?? "unknown");
  else if (status >= 400) dvrlog.warn("VHS-RESP", status, xhrLike?.url?.slice(0, 80));
  else if ((xhrLike?.url ?? "").includes(".m3u8")) dvrlog.info("VHS-RESP-OK", status);
});

Gotcha: onResponse’s first arg is the XHR-like object for ALL responses, not just errors. Logging [object XMLHttpRequest] blindly produces noise. Check error.status — a real transport error has status === 0.

The signed usher master playlist URL is the only HLS URL we hand VHS; VHS fetches variant playlists and segments itself in the page context (CORS works from twitch.tv — no background service worker relay needed, despite usher.ttvnw.net not serving CORS headers on OPTIONS preflight).

Native Player Suppression ⚠️ CRITICAL

Twitch renders its own <video> inside .video-player__container. Without suppression it plays a second audio feed underneath the video.js player. disableNativePlayer():

Skip logic — check .closest(), not just id

video.js wraps/replaces the original <video id="vjs-player">, renaming it to vjs-player_html5_api and possibly creating inner <video> elements with blob: (MediaSource) srcs. Checking v.id === "vjs-player" alone misses the renamed tech element — the recurring disableNativePlayer interval then mutes/pauses/hides our own player every second (the “no video” bug where audio played but video didn’t).

Correct skip:

if (v.id === "player" || v.id === "vjs-player" ||
    v.closest("#vjs-player-container") || v.closest(".video-js")) {
  skippedCount++; continue;
}

Recurring interval, not one-shot

Twitch rebuilds its player React tree async on navigation and ad breaks. A one-shot disableNativePlayer() at install is insufficient — a freshly-mounted native video plays alongside ours. Run disableNativePlayer() every 1s via an interval stashed on the player (cleared on dispose).

Stacking / z-index

The video.js container must sit above Twitch’s native player DOM. Even after the native <video> is display:none, Twitch’s wrapper divs occupy the space. Inject CSS:

#vjs-player-container {
  position: absolute !important; top: 0; left: 0;
  width: 100% !important; height: 100% !important;
  background: #000 !important;
  z-index: 2147483647 !important;  /* max int */
}
.video-player__container .video-player,
.video-player__container .player-overlay,
.video-player__container .click-handler,
.video-player__container .video-player__overlay,
.video-player__container [data-a-target="player-overlay"] {
  display: none !important;
}

Player Lifecycle Events (debug logging)

Wire these for full lifecycle visibility:

Quality: player.qualityLevels().on("change", ...) logs ABR switches.

Diagnostic Snapshot

A recurring (2s) snapshot of the .vjs-tech <video> element + container is invaluable for “no video” debugging. Query the real tech element (video.js renames the id):

const techEl = container.querySelector("video.vjs-tech, .vjs-tech video, video");

Log: videoWidth/videoHeight (intrinsic decoded size; 0 until decoded), readyState (4 = HAVE_ENOUGH_DATA), networkState, currentTime, paused, muted, src (blob: = MediaSource), and computed display/visibility/opacity/zIndex/position for both the video and container.

Decision tree for “no video”:

Channel Switch / Disposal

On channel navigation: player.dispose() tears down VHS + SourceBuffer + the tech element. Remove the container, re-run disableNativePlayer (×3: now / +500ms / +1500ms — Twitch remounts async), then create a fresh player for the new channel.

Gotchas Summary

Gotcha Symptom Fix
fluid: true on fixed-height container 0-height / overflowing video Use fill: true
video-js.min.css not loaded Borked controls, detached dropdowns Add to manifest css array
disableNativePlayer checks id only Own player disabled every 1s (no video, audio OK) Also check v.closest("#vjs-player-container") / v.closest(".video-js")
disableNativePlayer one-shot Second audio feed after Twitch remounts Run on 1s interval
onResponse logs [object XMLHttpRequest] Noisy useless logs First arg is XHR for all responses; check error.status
Runtime import videojs in content script +580KB bundle Type-only import type, load video.js as separate content_script
@types/videojs-hls-quality-selector hlsQualitySelector types don’t apply It augments non-existent VideoJsPlayer interface; re-declare on real Player
getElementById("vjs-player") for diagnostics Returns null / wrong element video.js renames to vjs-player_html5_api; query .vjs-tech
Chrome 141+ native HLS onRequest/onResponse hooks don’t fire overrideNative: true
Muted autoplay No audio muted: false (extension context permits unmuted autoplay)
VOD restore play() before seeked Stuck at readyState:1 on fully-muted VODs Defer play() to seeked one-shot handler
getElementById("vjs-player") for channel-switch detection Misroutes to installPlayer (no-op) instead of switchChannel Check getElementById("vjs-player-container") (never renamed by video.js)
Theater mode button click via DOM No-op — disableNativePlayer hides the overlay the button lives in Toggle .video-player__container--theatre class directly
player HTMLVideoElement not set on video.js path All keyboard shortcuts silently dead Set player from .vjs-tech element after install
fastQualityChange_() doesn’t flush buffer Old-quality data keeps playing after manual quality switch sb.remove(ct, removeEnd) + seek to segment start

VOD Page Playback (video.js path)

VOD pages (/videos/<id>) are handled by the same installVideoJS() + installVideoJSPlayer() pipeline:

  1. installVideoJS() detects VOD page via extractVodId(pathname).
  2. Resolves a signed VOD master URL via getVodHlsUrl(vodId) → getM3U8(false, vodId, ...) → usher.ttvnw.net/vod/<id>.m3u8.
  3. Hands the master URL to installVideoJSPlayer() — VHS plays it as a finite VOD (duration = finite, not Infinity).
  4. VOD position is restored after loadedmetadata via getSavedVodPosition(vodId).
  5. Position is saved every ~5s via timeupdate → saveVodPosition(vodId, now).

VOD Restore Must Wait for seeked Before play() — CRITICAL

When restoring a saved position, the code calls player.currentTime(savedPos) then player.play(). The browser treats the in-progress seek as an implicit pause of the play attempt, producing an AbortError. On fully-muted VODs this leaves the player stuck at readyState:1 forever — the decoder never transitions to HAVE_ENOUGH_DATA.

Fix: Defer play() to a seeked one-shot handler:

player.currentTime(savedPos);
player.one("seeked", () => {
  player.play().catch(e => dvrlog.info("play rejected after seek:", e));
});

This ensures the seek fully completes before play() is called, letting the decoder proceed normally.

Muted-Segment Detection (VHS onResponse)

Muted segments are detected in the VHS onResponse hook by scanning the response body for -muted.ts or index-muted- in the URL. Not a playlist parse — just a string check on the response URL. When detected, showToast("Muted segment – audio is copyright-silenced") fires.

The detection is in wireVhsHooks():

if (respUrl.includes("-muted.ts") || respUrl.includes("index-muted-")) {
  if (!mutedSegmentToastShown) {
    showToast("Muted segment – audio is copyright-silenced");
    mutedSegmentToastShown = true;
  }
}

A resetMutedSegmentToast() function resets the flag so it re-fires on the next VOD load.

Fully-Muted VOD Limitation

Some VODs are entirely muted — every segment in the variant playlist is *-muted.ts. VHS fetches and appends all segments, but the decoder may never reach readyState:4 (HAVE_ENOUGH_DATA). The video track plays but the audio is silenced. This is an upstream VHS limitation — the extension does not pause or error out. The toast is displayed once per VOD load.

What’s NOT Provided by video.js (needs custom code)

See rewrite.md for the full task list.

Theater Mode ⚠️ CRITICAL

Theater mode toggles the .video-player__container--theatre class on Twitch’s .video-player__container element directly via DOM manipulation. This is intentional — not a workaround for a bug — because:

  1. disableNativePlayer hides the overlays: The Twitch theater-mode button ([data-a-target="player-theatre-mode-button"]) lives inside .video-player__overlay, which disableNativePlayer hides with display:none. Clicking a hidden button’s click() does not reach React 18’s event delegation (React 18 listens on its root container, not document), so the toggle never fires.

  2. VOD pages may lack the button: On VOD pages (/videos/<id>), the theater mode button may not exist in the DOM at all.

  3. No 50ms setTimeout needed: DOM class toggling is synchronous, unlike the previous approach of clicking a button then waiting for React to update the DOM before syncing the DVR icon.

Implementation (src/lib/theater-mode.ts):

T key (in keyboard-handler.ts): Calls toggleTheaterMode() callback (from index.ts), NOT document.querySelector('[data-a-target="player-theatre-mode-button"]')?.click() — the old approach was broken on the video.js path and is now removed.

Quality Selection Buffer Flush ⚠️

When the user clicks a specific quality in the hls-quality-selector menu, VHS calls fastQualityChange_() which switches the variant playlist but does NOT flush the existing SourceBuffer. The old-quality data keeps playing while new-quality segments are appended behind it. On EVENT-type playlists (Twitch VODs), segments are sequential, so the old buffer never drains naturally.

Fix (in installVideoJSPlayer() ql.on("change") handler): After fastQualityChange_(), finds the SourceBuffer via vhs.mediaSource.sourceBuffers[0], calls sb.remove(ct, removeEnd) to remove the old buffer ahead of the playhead, then on updateend seeks to Math.floor(ct / 10) * 10 (start of the 10s segment boundary). This clears the stale buffer and snaps to the nearest segment start.

experimentalBufferBasedABR: true is enabled in the VHS config — runs ABR on a 250ms interval instead of only on segment download events, making both auto-switches and manual quality switches respond faster.

HTMLVideoElement Wiring for Keyboard Shortcuts ⚠️

The keyboardHandler function in keyboard-handler.ts guards most operations with if (!player) return; where player: HTMLVideoElement | null. On the video.js path, the player variable was never set — it stayed null, so ALL keyboard shortcuts (space, J/K/L, F, M, arrows, comma/period) were silently dead.

Fix (in installVideoJS() in index.ts): After installVideoJSPlayerExt() returns, the video.js tech element is queried and assigned to the module-level player variable:

player = result.container.querySelector("video.vjs-tech, .vjs-tech video, video") || null;

The video.js tech element is a plain HTMLVideoElement that supports all property reads/writes the keyboard handler needs (currentTime, volume, paused, playbackRate).

play/pause/seekToTime callbacks in index.ts also short-circuit to the vjsPlayer (video.js player wrapper) when present, so they work correctly on the video.js path regardless of the tech element reference.