src/lib/demo/screen-recorder.ts

Total Symbols
8
Lines of Code
223
Avg Complexity
5.0
Symbol Types
3

File Relationships

graph LR start["start"] acquireStream["acquireStream"] mimeForCodec["mimeForCodec"] startWithStreamId["startWithStreamId"] start -->|calls| acquireStream start -->|calls| mimeForCodec startWithStreamId -->|calls| mimeForCodec click start "../symbols/f0ed2d1a28e21447.html" click acquireStream "../symbols/1cf6715296051fa9.html" click mimeForCodec "../symbols/3b29e1b45edc4afa.html" click startWithStreamId "../symbols/04e27d4934028102.html"

Architecture violations

View all

  • [warning] max-cyclomatic-complexity: 'createScreenRecorder' has cyclomatic complexity 17 (max 10)
  • [warning] max-lines: 'createScreenRecorder' has 183 lines (max 80)

Symbols by Kind

method 4
function 3
interface 1

All Symbols

Name Kind Visibility Status Lines Signature
ScreenRecorder interface exported- 20-36 interface ScreenRecorder
createScreenRecorder function exported- 40-222 createScreenRecorder(): : ScreenRecorder
mimeForCodec function - 46-55 mimeForCodec(codec: "vp8" | "vp9"): : string
acquireStream function - 57-90 acquireStream( tabId: number, opts: ScreenRecordOptions, ): : Promise<MediaStream>
state method - 93-95 state()
start method - 97-135 start(tabId, optionsOverride)
startWithStreamId method - 137-192 startWithStreamId(streamId, optionsOverride)
stop method - 194-220 stop()

Full Source

/**
 * Screen Recorder — captures the active tab as WebM video via
 * `chrome.tabCapture` + `MediaRecorder`, fully client-side.
 *
 * Lifecycle:
 *   1. `startRecording(tabId)` → acquires a MediaStream, begins encoding.
 *   2. `stopRecording()` → finalises the recording, returns a Blob.
 *
 * All data stays local — no upload, no external API.
 */

import { createLogger } from "@/lib/logger";
import type { RecordingState, ScreenRecordOptions } from "./demo.types";
import { DEFAULT_SCREEN_RECORD_OPTIONS } from "./demo.types";

const log = createLogger("ScreenRecorder");

// ── Types ─────────────────────────────────────────────────────────────────

export interface ScreenRecorder {
  /** Current recording state */
  readonly state: RecordingState;
  /** Start capturing the given tab (background/extension page context required) */
  start(tabId: number, options?: Partial<ScreenRecordOptions>): Promise<void>;
  /**
   * Start recording from an already-obtained `chrome.tabCapture` stream ID.
   * Use this from DevTools panel pages which have access to `navigator.mediaDevices`
   * but cannot call `chrome.tabCapture.getMediaStreamId` on behalf of another tab.
   */
  startWithStreamId(
    streamId: string,
    options?: Partial<ScreenRecordOptions>,
  ): Promise<void>;
  /** Stop capturing and return the recorded Blob (WebM) */
  stop(): Promise<Blob>;
}

// ── Factory ───────────────────────────────────────────────────────────────

export function createScreenRecorder(): ScreenRecorder {
  let state: RecordingState = "inactive";
  let recorder: MediaRecorder | null = null;
  let stream: MediaStream | null = null;
  let chunks: Blob[] = [];

  function mimeForCodec(codec: "vp8" | "vp9"): string {
    const candidate = `video/webm;codecs=${codec}`;
    if (
      typeof MediaRecorder !== "undefined" &&
      MediaRecorder.isTypeSupported(candidate)
    ) {
      return candidate;
    }
    return "video/webm";
  }

  async function acquireStream(
    tabId: number,
    opts: ScreenRecordOptions,
  ): Promise<MediaStream> {
    // chrome.tabCapture.getMediaStreamId requires an active tab
    const streamId = await new Promise<string>((resolve, reject) => {
      chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => {
        if (chrome.runtime.lastError) {
          reject(new Error(chrome.runtime.lastError.message));
        } else {
          resolve(id);
        }
      });
    });

    const constraints: MediaStreamConstraints = {
      audio: opts.includeAudio
        ? ({
            mandatory: {
              chromeMediaSource: "tab",
              chromeMediaSourceId: streamId,
            },
          } as MediaTrackConstraints)
        : false,
      video: {
        mandatory: {
          chromeMediaSource: "tab",
          chromeMediaSourceId: streamId,
        },
      } as MediaTrackConstraints,
    };

    return navigator.mediaDevices.getUserMedia(constraints);
  }

  return {
    get state() {
      return state;
    },

    async start(tabId, optionsOverride) {
      if (state !== "inactive") {
        log.warn("Recording already active");
        return;
      }

      const opts: ScreenRecordOptions = {
        ...DEFAULT_SCREEN_RECORD_OPTIONS,
        ...optionsOverride,
      };

      try {
        stream = await acquireStream(tabId, opts);
        chunks = [];

        const mimeType = mimeForCodec(opts.codec);
        recorder = new MediaRecorder(stream, {
          mimeType,
          videoBitsPerSecond: opts.videoBitrate,
        });

        recorder.ondataavailable = (e: BlobEvent) => {
          if (e.data.size > 0) chunks.push(e.data);
        };

        recorder.onerror = (e) => {
          log.error("MediaRecorder error:", e);
          state = "inactive";
        };

        recorder.start(1000); // 1 s timeslice for progressive encoding
        state = "recording";
        log.info(`Recording started (tab ${tabId}, codec=${opts.codec})`);
      } catch (err) {
        log.error("Failed to start recording:", err);
        state = "inactive";
        throw err;
      }
    },

    async startWithStreamId(streamId, optionsOverride) {
      if (state !== "inactive") {
        log.warn("Recording already active");
        return;
      }

      const opts: ScreenRecordOptions = {
        ...DEFAULT_SCREEN_RECORD_OPTIONS,
        ...optionsOverride,
      };

      const constraints: MediaStreamConstraints = {
        audio: opts.includeAudio
          ? ({
              mandatory: {
                chromeMediaSource: "tab",
                chromeMediaSourceId: streamId,
              },
            } as MediaTrackConstraints)
          : false,
        video: {
          mandatory: {
            chromeMediaSource: "tab",
            chromeMediaSourceId: streamId,
          },
        } as MediaTrackConstraints,
      };

      try {
        stream = await navigator.mediaDevices.getUserMedia(constraints);
        chunks = [];

        const mimeType = mimeForCodec(opts.codec);
        recorder = new MediaRecorder(stream, {
          mimeType,
          videoBitsPerSecond: opts.videoBitrate,
        });

        recorder.ondataavailable = (e: BlobEvent) => {
          if (e.data.size > 0) chunks.push(e.data);
        };

        recorder.onerror = (e) => {
          log.error("MediaRecorder error:", e);
          state = "inactive";
        };

        recorder.start(1000);
        state = "recording";
        log.info(`Recording started via streamId, codec=${opts.codec}`);
      } catch (err) {
        log.error("Failed to start recording from streamId:", err);
        state = "inactive";
        throw err;
      }
    },

    async stop() {
      if (state !== "recording" || !recorder || !stream) {
        return new Blob([], { type: "video/webm" });
      }

      state = "stopping";

      return new Promise<Blob>((resolve) => {
        recorder!.onstop = () => {
          const blob = new Blob(chunks, {
            type: recorder!.mimeType || "video/webm",
          });
          log.info(`Recording stopped — ${(blob.size / 1024).toFixed(1)} KB`);

          // Release tracks
          stream!.getTracks().forEach((t) => t.stop());
          stream = null;
          recorder = null;
          chunks = [];
          state = "inactive";

          resolve(blob);
        };

        recorder!.stop();
      });
    },
  };
}