Architecture
Overview
Twitch DVR is a Chrome extension that replaces Twitch.tv’s native video player with a video.js v8 player backed by @videojs/http-streaming (VHS). VHS owns the entire HLS pipeline — playlist fetching, segment downloading, mux.js transmuxing, MediaSource + SourceBuffer management, buffer eviction, live DVR seek bar, and ABR quality switching. The extension provides the signed Twitch HLS master URL and suppresses Twitch’s native player; video.js handles playback.
The custom hand-rolled MSE pipeline that powered the extension previously has been deleted. The codebase now only uses the video.js v8 + VHS player path.
The extension is split into two independent parts:
- Content script (
src/content/index.ts) — the video.js installer + lifecycle, native player suppression, URL/tick orchestration. ~1500 lines. - Popup page — a SvelteKit app (largely template placeholder).
Data Flow (video.js)
Live Playback
handleInstallPlayer() (called by runMain on channel pages)
└── installVideoJS()
├── getChannelName(path) → "bubbell"
├── getLiveHlsUrl(channel)
│ ├── getClientId() → public Twitch web client ID
│ ├── getM3U8(true, channel) → GQL PlaybackAccessToken_Template
│ │ → fetch usher master.m3u8
│ │ → parseMasterManifest() → Variant[]
│ └── return variants[0].url (signed master playlist URL)
└── installVideoJSPlayer(hlsUrl, changelog)
├── disableNativePlayer() (mute/hide Twitch's <video>)
├── create #vjs-player-container (absolute, z-index max)
├── wireVhsHooks() (onRequest/onResponse logging)
├── videojs("vjs-player", {
│ fill: true, (NOT fluid — fills the container)
│ liveui: true, (DVR seek bar + seek-to-live)
│ overrideNative: true, (VHS over Chrome 141+ native HLS)
│ liveTracker: {...},
│ html5.vhs: {...},
│ })
├── player.ready → hlsQualitySelector() + player.src(hlsUrl)
└── arm 1s native-disable interval (Twitch remounts native async)
│
▼
VHS takes over:
fetch master → fetch variant playlist → fetch segments →
transmux (mux.js bundled) → SourceBuffer.appendBuffer() →
<video> renders. Live DVR window tracked via liveTracker.
VOD Page Playback
installVideoJS() detects VOD page via extractVodId(pathname)
├── getVodHlsUrl(vodId)
│ ├── getM3U8(false, vodId, clientId, authState) → signed master URL
│ └── return master URL (usher.ttvnw.net/vod/<id>.m3u8)
└── installVideoJSPlayer(hlsUrl, changelog) (same as live path)
├── VHS plays it as a finite VOD (duration !== Infinity)
├── loadedmetadata → seek to saved position (if any)
│ └── player.currentTime(savedPos) → wait for "seeked" → play()
└── timeupdate → save position every ~5s (chrome.storage.local)
Channel Navigation
handleUrlChange() detects new channel (url-watcher.ts)
└── switchChannel()
├── If existing vjsPlayer: change source on existing player
│ ├── Resolve new HLS URL (live or VOD)
│ ├── vjsPlayer.src({ src: hlsUrl, type: "application/x-mpegURL" })
│ ├── vjsPlayer.play()
│ └── disableNativePlayer() ×3 (now, 500ms, 1500ms)
└── No existing player: fresh install
└── installVideoJS() (reads new channel from pathname)
Channel switch source reuse: Rather than disposing + recreating the player (which corrupts VHS’s internal XHR state), switchChannel() reuses the existing player and calls player.src() with a new HLS URL. VHS handles source transitions natively (dispose + recreate the internal tech pipeline without touching the player shell).
Live detection
video.js identifies a live stream via a durationchange event where duration === Infinity (per the official live guide). Our logs confirm this fires: loadedmetadata — duration: Infinity seekable ranges: 1. This activates the vjs-live + vjs-liveui classes and the LiveTracker, which provides seekableStart(), seekableEnd(), liveWindow(), liveCurrentTime(), atLiveEdge(), and seekToLiveEdge().
Native Player Suppression
Twitch renders its own <video> inside .video-player__container. Without suppression it plays a second audio feed underneath the video.js player. disableNativePlayer() (in src/lib/native-player.ts) handles this:
- Mutes, pauses, sets
display:none, removescontrols, and unfocuses every.video-player__container videoexcept ours. - Hides Twitch’s overlay DOM:
.player-overlay,.click-handler,.video-player__overlay. - Skip logic is critical: video.js wraps/replaces the original
<video id="vjs-player">, renaming it tovjs-player_html5_apiand possibly creating inner<video>elements withblob:(MediaSource) srcs. The skip checksv.id === "player" || v.id === "vjs-player" || v.closest("#vjs-player-container") || v.closest(".video-js"). Checking the id alone misses the renamed tech element — that caused “no video” (the interval disabled our own player every second). - Called once at install, then every 1s via an interval stashed on the player (cleared on
dispose). Twitch rebuilds its player React tree async on navigation and ad breaks, so a one-shot disable is insufficient.
CSS / Stacking
The video.js container must sit above Twitch’s native player DOM. Two pieces:
#vjs-player-containerhasz-index: 2147483647(max int32) to stack above everything.- Twitch wrapper divs hidden:
.video-player,.player-overlay,.click-handler,.video-player__overlay,[data-a-target="player-overlay"]— alldisplay: none !important.
Without step 2, the video.js container sits behind Twitch’s DOM even after the native <video> is hidden, because Twitch’s wrapper divs still occupy the space and their z-index stacking context outranks ours.
Custom CSS is injected via addStyle() in src/content/index.ts for: video.js player container, custom seek bar, timer elements, speed popover, theater mode, quality picker, chat overlay, toast, and overlays.
Player runMain Loop
runMain() in src/lib/player-main.ts runs a 1s setInterval that checks for URL changes via handleUrlChange(). On URL change it routes through switchChannel() (source reuse) or installPlayer() (fresh install) or uninstallPlayer() (cleanup). No playerTick is needed — the video.js player handles all playback internally.
Module Architecture
Active Modules
src/content/index.ts — Main entry: install/lifecycle/switch/cleanup
src/lib/videojs-install.ts — video.js player creation, VHS hooks, HLS URL resolution
src/lib/player-main.ts — runMain loop (1s URL change detection)
src/lib/url-watcher.ts — URL change routing
src/lib/native-player.ts — disableNativePlayer (Twitch video suppression)
src/lib/keyboard-handler.ts — Keyboard shortcuts
src/lib/volume.ts — Volume slider + mute toggle
src/lib/play-pause.ts — pause/play state management
src/lib/speed.ts — Speed control
src/lib/seek-bar.ts — Seek bar DOM helpers
src/lib/switch-mode.ts — Live/VOD mode switching
src/lib/storage.ts — Persistence (chrome.storage.local + localStorage)
src/lib/utils.ts — Shared utilities
src/lib/schemas.ts — Zod validation schemas
src/lib/auth.ts — Twitch auth (OAuth token, client ID)
src/lib/page-helpers.ts — DOM/path helpers
src/lib/vod-duration.ts — VOD duration fetching
src/lib/overlays.ts — Error/ad overlays
src/lib/vod-position.ts — VOD position save/restore
src/lib/dom-helpers.ts — DOM helpers (addStyle)
src/lib/toast.ts — Toast notifications
src/lib/theater.ts — Theater mode helpers
src/lib/theater-mode.ts — Theater mode state management
src/lib/fullscreen.ts — Fullscreen enter/exit
src/lib/gql.ts — GQL API client
src/lib/chat-replay.ts — Chat replay sync
src/lib/chat-overlay.ts — Chat overlay
src/lib/fetch-storyboard.ts — Storyboard sprite sheet fetching
src/lib/resolve-vod-url.ts — VOD URL resolution via GQL
src/lib/get-m3u8.ts — HLS master URL resolution
src/lib/clip.ts — Clip creation
src/lib/parse-vod-playlist.ts — VOD variant playlist parsing
src/lib/settings.ts — Settings menu
src/lib/speed-popover.ts — Speed popover
src/lib/picker.ts — Quality picker
src/lib/controls-toggle.ts — Control bar show/hide
src/lib/uninstall-player.ts — Player cleanup/teardown
Dead Modules (Deleted)
The following modules were part of the legacy custom MSE pipeline and have been deleted:
buffer-live.ts,buffer-vod.ts,download-segments.ts,append-to-source-buffer.tsforce-buffer-trim.ts,after-buffer-update.ts,set-variant.ts,rebuffer.tsseek-to-time.ts,execute-seek-to-time.ts,seek.ts,debounce-seek.tsreset-transmuxer.ts,clear-timers.ts,golive.ts,get-live-m3u8.tsswitch-channel.ts,toggle-player.ts,on-seek-down.ts,vod-gap.tsget-vod-url.ts,player-tick.ts,total-elapsed.ts,budget.tsinstall-player.ts
Build Pipeline
Content script built via build-content.mjs (esbuild), output to static/index.js. The same script copies video.js + plugin assets to static/: video.min.js, video-js.min.css, plugins/quality-levels.min.js, plugins/hls-quality-selector.min.js. mux.js is NOT copied (VHS bundles it). video.js is loaded as a separate content_script (runtime global), NOT bundled into index.js.
SvelteKit build via vite build for the extension UI, output to build/. copy-mux.mjs then copies the same video.js assets to build/.
Full build: bun run build → build-content.mjs → vite build → copy-mux.mjs → clean-underscore.mjs.