Development Guide
Prerequisites
- Bun (the project uses
bun.lock) - Chrome or any Chromium-based browser
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/:
video.min.js(video.js v8 + VHS bundled, includes mux.js internally)video-js.min.css(REQUIRED — without it the control bar + quality menu render unstyled/detached)plugins/quality-levels.min.js(videojs-contrib-quality-levels)plugins/hls-quality-selector.min.js(videojs-hls-quality-selector)
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
- Edit
src/content/index.ts - Run
bun run build:content - 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]:
dvrlog.debug(...)— detailed tracing (hidden by default in Chrome devtools — tick “Verbose” in the console filter)dvrlog.info(...)— notable state changesdvrlog.warn(...)— non-fatal issuesdvrlog.error(...)— errors
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”:
videojs.getLiveHlsUrl – resolved {variants, host}— signed master URL resolved (6 variants expected).videojs: video.js container created+parent .video-player__container box: {w,h,...}— DOM container mounted. Ifh:0, the parent has no height.videojs: video.js player ready, loading HLS source: ...— player.ready fired.videojs.VHS-REQ/videojs.VHS-RESP-OK 200— playlist fetches (~every 2s on live).VHS-RESP ≥400orVHS-ERRmeans a CDN/token problem.videojs: video.js loadedmetadata — duration: Infinity seekable ranges: 1— live detected viadurationchange → Infinity(per the video.js live guide).videojs: video.js loadeddata — first frame decodedthenplaying — playback started— decode pipeline alive. Ifplayingnever fires, VHS isn’t feeding frames.videojs: videojs diag: {vid:{...}, cont:{...}}— recurring (2s) snapshot of the.vjs-tech<video>element + container:videoW/videoH(intrinsic decoded size; 0 until decoded),readyState(4 = HAVE_ENOUGH_DATA),currentTime,display/visibility/opacity/zIndex/positionfor both. Ifvid.w/h > 0andcont.h > 0anddisplay:blockbut no video shows, it’s a stacking/visibility issue (z-index, an overlay covering it).disableNativePlayer – videos: (installVideoJSPlayer-interval) {total, disabled, skippedDvr}— runs every 1s.skippedDvrshould count our video.js<video>(idvjs-player_html5_api, inside.video-js/#vjs-player-container). Ifdisabledcatches our video (skippedDvr: 0while ours exists), the skip logic broke — see AGENTS.md.
VOD-specific diagnostics
installVideoJS – VOD page, vodId: <id>— VOD page detected.videojs.getVodHlsUrl – fetching signed master for VOD: <id>— VOD master URL resolution started.installVideoJS – VOD restore setup start, savedPos: <n> readyState: <n>— position restore path.installVideoJS – VOD ready() callback, readyState: <n> hasLoadedMeta: <bool>— ready fired.installVideoJS – seeking to savedPos: <n>—currentTime()called.installVideoJS – seeked, calling play()— seek completed, play() deferred toseekedhandler.installVideoJS – play() rejected after seek: AbortError— play() still raced with seek (should not happen with theseekedfix).videojs: detected muted segment playlist – showing toast— first muted segment detected via VHSonResponse.videojs: videojs diag: ... readyState:1 ...— stuck at HAVE_METADATA, not enough frames decoded (fully-muted VOD limitation).installVideoJS – play() rejected after restore: AbortError— old behavior (pre-fix); if this appears, the seek is still racing with play().
Lessons learned (already fixed, documented to prevent regression):
fluid: truecollapses the player height via apadding-topaspect-ratio trick (height:0 !important); combined with a fixed-height container this produced a 0-height/overflowing video. Usefill: truewhen the container is already sized. Seedocs/videojs.md.- The
video-js.min.cssstylesheet MUST be in the manifestcssarray. Without it the controls/menus render unstyled and detached from the player. disableNativePlayermust skip the video.js tech element, not justid === "vjs-player"— video.js renames it tovjs-player_html5_apiand may create inner<video>s withblob:srcs. Checkv.closest("#vjs-player-container") || v.closest(".video-js").- VOD position restore: wait for
seekedbeforeplay()— callingplayer.currentTime(savedPos)thenplayer.play()immediately races with the seek. The browser treats the seek as an implicit pause, producing anAbortError. On fully-muted VODs the player never reachesreadyState:4. Fix:player.one("seeked", () => player.play()). getElementById("vjs-player")for channel-switch detection returns null after first install (video.js renames it). CheckgetElementById("vjs-player-container")instead — our wrapper div is never renamed.- Theater mode: toggle DOM class directly — do NOT click
[data-a-target="player-theatre-mode-button"].disableNativePlayerhides the overlay it lives in, blocking React 18 event delegation. Toggle.video-player__container--theatreon.video-player__containerdirectly. Seedocs/videojs.mdfor full detail. - Keyboard shortcuts:
playerHTMLVideoElement must be set — on the video.js path, query the tech element (video.vjs-tech) after install and assign to the module-levelplayervariable. All keyboard shortcuts guard withif (!player) return;. Seedocs/videojs.mdfor full detail. - Quality selection: flush old buffer after switch — VHS’s
fastQualityChange_()doesn’t flush the SourceBuffer. After switching quality, remove old buffer ahead of playhead (sb.remove(ct, removeEnd)) and seek to segment start (Math.floor(ct / 10) * 10). Seedocs/videojs.mdfor full detail.
Codebase Conventions
Architecture Boundaries
src/lib/— pure logic only. No browser globals, no side effects. All functions are testable with Vitest.src/content/— side effects. DOM manipulation,fetch,WebSocket, MSE APIs, browser state.
Validation
All data crossing the I/O boundary must be validated with Zod schemas from src/lib/schemas.ts. This includes:
- GQL API responses
document.cookievalueslocalStoragevalues- HLS playlist entries
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.