Skip to the content.

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:

  1. Content script (src/content/index.ts) — the video.js installer + lifecycle, native player suppression, URL/tick orchestration. ~1500 lines.
  2. 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:

CSS / Stacking

The video.js container must sit above Twitch’s native player DOM. Two pieces:

  1. #vjs-player-container has z-index: 2147483647 (max int32) to stack above everything.
  2. Twitch wrapper divs hidden: .video-player, .player-overlay, .click-handler, .video-player__overlay, [data-a-target="player-overlay"] — all display: 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:

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.