Skip to the content.

Development Guide

Prerequisites

Quick Start

git clone https://github.com/ashleyjackson/twitch-dvr.git
cd twitch-dvr
bun install
bun run dev

Build Pipeline

The project has a two-part build process:

1. Content Script (src/content/index.ts → static/index.js)

Built by build-content.mjs using esbuild:

bun run build:content

This produces a single IIFE bundle (static/index.js). The content script installs a video.js v8 + VHS player and delegates HLS playback, MSE, transmuxing, live DVR, and ABR to VHS. The legacy custom MSE pipeline has been deleted.

build-content.mjs also copies video.js + plugin assets into static/:

These are loaded as separate content_scripts entries in manifest.json (JS before index.js; CSS via the css array). video.js is NOT bundled into index.js (would add ~580KB); it’s consumed as a runtime global declared in src/content/videojs.d.ts.

2. Popup Page (src/routes/ → build/)

Built by SvelteKit + Vite using the sveltekit-adapter-chrome-extension adapter:

bun run build  # runs both steps

Output goes to build/. The adapter copies static/manifest.json into the build output. copy-mux.mjs then copies the same video.js assets into build/ (mux.js itself is no longer copied — VHS bundles it).

Full Build

bun run build

Sequences: build-content.mjs → vite build → copy-mux.mjs → clean-underscore.mjs

clean-underscore.mjs removes any _-prefixed files/directories from build/. Chrome MV3 reserves names starting with _; SvelteKit can emit orphaned _layout.*.css assets that make unpacked extension load fail.

This is the complete local test path — run it, then reload the unpacked build/ directory at chrome://extensions.

Development Workflow

Editing the content script

  1. Edit src/content/index.ts
  2. Run bun run build:content
  3. Reload the extension at chrome://extensions

Running tests

bun run test:unit

Or in watch mode:

bunx vitest

Debugging

The content script uses a structured logger (dvrlog) that prefixes all output with [TwitchDVR]:

Filter the console with [TwitchDVR] to see only extension logs.

video.js diagnostics (live player)

The video.js path is instrumented end-to-end. Key log lines when diagnosing “no video / no audio”:

VOD-specific diagnostics

Lessons learned (already fixed, documented to prevent regression):

Codebase Conventions

Architecture Boundaries

Validation

All data crossing the I/O boundary must be validated with Zod schemas from src/lib/schemas.ts. This includes:

State Management

The content script uses module-level let variables. The generation counter pattern is used for cancellation:

let generation = 0;

async function doWork() {
  const startGen = generation;
  const result = await fetch(url);
  if (startGen !== generation) return; // stale, discard
  process(result);
}

When the player is reset, generation is incremented and all in-flight operations silently abort.

Logging

Use dvrlog (not raw console.*) for all extension logging. Info-level logs should be meaningful enough to diagnose issues in production without requiring debug-level verbosity.

Testing

Tests live in tests/ and use Vitest. They import functions from $lib/utils (aliased via the SvelteKit $lib alias).

Adding a new test

// tests/myFeature.test.ts
import { describe, it, expect } from "vitest";
import { myFunction } from "$lib/utils";

describe("my feature", () => {
  it("does the thing", () => {
    expect(myFunction(input)).toBe(expected);
  });
});

Key test files (32 files, 280 tests)

File What it covers
url-watcher.test.ts handleUrlChange: channel detection, reinstall skip, switch, uninstall, currentUrl always updates on throw
volume.test.ts setVolume: clamp, player volume, #volume-slider + .slider-filled width, null guards. toggleMute: mute/unmute, lastKnownVolume, toasts
keyboardHandler.test.ts Keyboard shortcut dispatch, capture-phase, null e.target guard, showToast for seeks/volume
parseMasterManifest.test.ts HLS master manifest parsing
navigation.test.ts URL parsing (channel name, page detection)
api.test.ts Cookie parsing, GQL token format
chatReplay.test.ts Chat embed URL construction
formatTime.test.ts Timestamp formatting
seek.test.ts Seek bar calculations
settingsVolume.test.ts Volume, budget, mode switching
dvrlog.test.ts Logger output format
storage.test.ts KEYS namespacing, accessors, chrome.storage fallback
storage-schemas.test.ts Zod validation for VOD/speed/chat/variant storage values
theater.test.ts applyTheaterState idempotency
native-player.test.ts disableNativePlayer: mutes/hides native video, skips #player, WARN on active playback
fullscreen.test.ts Fullscreen enter/exit, theater restore target
picker.test.ts Quality variant picker selection
speed-popover.test.ts Speed popover open/close, active highlight, outside-click close
settings.test.ts Settings menu items (chat overlay toggle, etc.)
theater-mode.test.ts setTheaterMode/toggleTheaterMode/restoreTheaterMode — DOM class toggle, icon sync, tolerance for missing elements
controls-toggle.test.ts Control bar show/hide toggles
uninstall-player.test.ts uninstallPlayer: cleanup, timer clear
clip.test.ts Clip button action
seek-bar.test.ts Seek bar DOM update helpers
speed.test.ts stepSpeed, applySpeed, SPEED_OPTIONS clamp
play-pause.test.ts play/pause: behind-live offset, black-screen watchdog arm/skip on adBreakActive
auth.test.ts getOauthToken, getClientId
vod-position.test.ts getSavedVodPosition, saveVodPosition, clearVodPosition
page-helpers.test.ts extractVodId, getChannelFromPage
vod-duration.test.ts fetchVODDuration
toast.test.ts showToast
chat-overlay.test.ts isChatOverlayEnabled, setChatOverlayEnabled, loadChatOverlayPos, saveChatOverlayPos

CI

The repository includes a GitHub Actions workflow (.github/workflows/ai-pr.yml) that automatically reviews pull requests using the OpenClaude CLI. The review prompt template is at .github/prompt.txt.

Packaging

bun run package

Builds and zips the extension into extension.zip, ready for upload to the Chrome Web Store.