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:
- Live: https://legacy.videojs.org/guides/live/
- Layout (fluid vs fill): https://legacy.videojs.org/guides/layout/
- Setup: https://legacy.videojs.org/guides/setup/
- Options: https://legacy.videojs.org/guides/options/
- Troubleshooting: https://legacy.videojs.org/guides/troubleshooting/
- Debugging: https://legacy.videojs.org/guides/debugging/
Versions
- video.js: 8.23.9 (latest stable v8). v10 is in beta (different architecture, VHS→hls.js) — migration deferred until GA + migration guides exist.
- @videojs/http-streaming (VHS): 3.17.5 — bundled inside
video.min.js, includes mux.js internally (no separate mux.js needed). - videojs-contrib-quality-levels: 4.1.0 —
qualityLevels()plugin. - videojs-hls-quality-selector: 2.0.0 — quality menu UI (v8-compatible).
- @types/videojs-hls-quality-selector: ships a
VideoJsPlayeraugmentation that targets a non-existent interface (see Gotchas).
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:
- Load
video.min.js(+plugins/quality-levels.min.js,plugins/hls-quality-selector.min.js) as separatecontent_scriptsJS entries inmanifest.json, beforeindex.js. - Load
video-js.min.cssvia the manifestcssarray. - Consume video.js as a runtime global (
videojs(...)). The global is declared insrc/content/videojs.d.ts, which usesimport 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():
- Mutes, pauses,
display:none, removescontrols, unfocuses every.video-player__container videoexcept ours. - Hides Twitch overlay DOM:
.player-overlay,.click-handler,.video-player__overlay.
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:
loadedmetadata— duration + seekable ranges (live:duration: Infinity)loadeddata— first frame decoded (decode pipeline alive)playing— playback started (the “silent failure” signal; if never fires, VHS isn’t feeding frames)waiting— rebuffer (dedupe so it doesn’t spam every tick; reset onplaying)stalled— no data incomingended— stream exhausted (unexpected on live)dispose— teardown ran (channel switch / uninstall)error—player.error()gives{code, message}
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”:
vid.w/h === 0→ container has no size (z-index/position/parent height issue).videoW/videoH === 0butvid.w/h > 0→ element sized but no frames decoded (VHS not feeding; checkVHS-RESPlogs).readyState < 4→ buffering, not enough data.vid.w/h > 0,videoW/H > 0,display:block,opacity:1but no video → stacking/visibility (an overlay covering it; bump z-index, hide Twitch wrappers).currentTimenot advancing butpaused:false→ playback stalled.
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:
installVideoJS()detects VOD page viaextractVodId(pathname).- Resolves a signed VOD master URL via
getVodHlsUrl(vodId)→getM3U8(false, vodId, ...)→usher.ttvnw.net/vod/<id>.m3u8. - Hands the master URL to
installVideoJSPlayer()— VHS plays it as a finite VOD (duration = finite, not Infinity). - VOD position is restored after
loadedmetadataviagetSavedVodPosition(vodId). - 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)
- GQL VOD detection / VOD URL resolution (
getVODUrl) — keep as-is - Muted VOD segment PTS rewrite (if VHS’s mux.js path has the same issue — needs verification)
- Ad segment detection/skip (via VHS
onResponseplaylist scan) - Storyboard seek preview (custom component)
- Chat replay sync (sync hidden native player
currentTime) - Chat overlay (fullscreen draggable iframe)
- Custom overlays (ad, mute, error)
- Custom keyboard shortcuts (J/K/L, speed) — video.js has built-in shortcuts; extend or replace
- Theater mode — toggles
.video-player__container--theatreclass directly (see Gotchas) - VOD seek on live channels (“switch to VOD CDN” mode — no v8 equivalent)
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:
-
disableNativePlayerhides the overlays: The Twitch theater-mode button ([data-a-target="player-theatre-mode-button"]) lives inside.video-player__overlay, whichdisableNativePlayerhides withdisplay:none. Clicking a hidden button’sclick()does not reach React 18’s event delegation (React 18 listens on its root container, notdocument), so the toggle never fires. -
VOD pages may lack the button: On VOD pages (
/videos/<id>), the theater mode button may not exist in the DOM at all. -
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):
toggleTheaterMode()— flips the class viacontainer.classList.toggle("video-player__container--theatre"), then syncs the DVR icon.setTheaterMode(target)— sets the class to a specific target state (idempotent).restoreTheaterMode()— reads savedKEYS.theaterpreference, applies it.syncDvrIcon()— reads the page layout state (isPageInTheater()checks for.video-player__container--theatre) and applies the correct class to the#theaterDVR icon button.
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.