chromium/content/test/data/media/peerconnection-multiple-streams.js

/*
 * Copyright 2017 The Chromium Authors
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file.
 */
/*jshint esversion: 6 */

/**
 * A loopback peer connection with one or more streams.
 */
class PeerConnection {
  /**
   * Creates a loopback peer connection. One stream per supplied resolution is
   * created.
   * @param {!Element} videoElement the video element to render the feed on.
   * @param {!Array<!{x: number, y: number}>} resolutions. A width of -1 will
   *     result in disabled video for that stream.
   */
  constructor(videoElement, resolutions) {
    this.localConnection = null;
    this.remoteConnection = null;
    this.remoteView = videoElement;
    this.streams = [];
    // Ensure sorted in descending order to conveniently request the highest
    // resolution first through GUM later.
    this.resolutions = resolutions.slice().sort((x, y) => y.w - x.w);
    this.activeStreamIndex = resolutions.length - 1;
    this.badResolutionsSeen = 0;
    this.localAudioTransceiver = null;
    this.localVideoTransceiver = null;
  }

  /**
   * Starts the connections. Triggers GetUserMedia and starts
   * to render the video on {@code this.videoElement}.
   * @return {!Promise} a Promise that resolves when everything is initalized.
   */
  async start() {
    // getUserMedia fails if we first request a low resolution and
    // later a higher one. Hence, sort resolutions above and
    // start with the highest resolution here.
    const promises = this.resolutions.map((resolution) => {
      const constraints = createMediaConstraints(resolution);
      return navigator.mediaDevices
        .getUserMedia(constraints)
        .then((stream) => this.streams.push(stream));
    });
    await Promise.all(promises);
    // Start with the smallest video to not overload the machine instantly.
    await this.createPeerConnections_(this.streams[this.activeStreamIndex]);
  };

  /**
   * Verifies that the state of the streams are good. The state is good if all
   * streams are active and their video elements report the resolution the
   * stream is in. Video elements are allowed to report bad resolutions
   * numSequentialBadResolutionsForFailure times before failure is reported
   * since video elements occasionally report bad resolutions during the tests
   * when we manipulate the streams frequently.
   * @param {number=} numSequentialBadResolutionsForFailure number of bad
   *     resolution observations in a row before failure is reported.
   * @param {number=} allowedDelta allowed difference between expected and
   *     actual resolution. We have seen videos assigned a resolution one pixel
   *     off from the requested.
   */
  verifyState(numSequentialBadResolutionsForFailure=10, allowedDelta=1) {
    this.verifyAllStreamsActive_();
    const expectedResolution = this.resolutions[this.activeStreamIndex];
    if (expectedResolution.w < 0 || expectedResolution.h < 0) {
      // Video is disabled.
      return;
    }
    if (!isWithin(
            this.remoteView.videoWidth, expectedResolution.w, allowedDelta) ||
        !isWithin(
            this.remoteView.videoHeight, expectedResolution.h, allowedDelta)) {
      this.badResolutionsSeen++;
    } else if (
        this.badResolutionsSeen < numSequentialBadResolutionsForFailure) {
      // Reset the count, but only if we have not yet reached the limit. If the
      // limit is reached, let keep the error state.
      this.badResolutionsSeen = 0;
    }
    if (this.badResolutionsSeen >= numSequentialBadResolutionsForFailure) {
      throw new Error(
          'Expected video resolution ' +
          resStr(expectedResolution.w, expectedResolution.h) +
          ' but got another resolution ' + this.badResolutionsSeen +
          ' consecutive times. Last resolution was: ' +
          resStr(this.remoteView.videoWidth, this.remoteView.videoHeight));
    }
  }

  verifyAllStreamsActive_() {
    if (this.streams.some((x) => !x.active)) {
      throw new Error('At least one media stream is not active')
    }
  }

  /**
   * Switches to a random stream, i.e., use a random resolution of the
   * resolutions provided to the constructor.
   * @return {!Promise} A promise that resolved when everything is initialized.
   */
  async switchToRandomStream() {
    await this.stopSending_();
    const newStreamIndex = Math.floor(Math.random() * this.streams.length);
    await this.startSending_(this.streams[newStreamIndex]);
    this.activeStreamIndex = newStreamIndex;
  }

  async createPeerConnections_(stream) {
    this.localConnection = new RTCPeerConnection();
    this.localConnection.onicecandidate = (event) => {
      this.onIceCandidate_(this.remoteConnection, event);
    };
    this.remoteConnection = new RTCPeerConnection();
    this.remoteConnection.onicecandidate = (event) => {
      this.onIceCandidate_(this.localConnection, event);
    };
    this.remoteConnection.ontrack = (e) => {
      this.remoteView.srcObject = e.streams[0];
    };

    const [audioTrack] = stream.getAudioTracks();
    const [videoTrack] = stream.getVideoTracks();
    this.localAudioTransceiver =
        audioTrack ? this.localConnection.addTransceiver(audioTrack) : null;
    this.localVideoTransceiver =
        videoTrack ? this.localConnection.addTransceiver(videoTrack) : null;
    await this.renegotiate_();
  }

  async startSending_(stream) {
    const [audioTrack] = stream.getAudioTracks();
    const [videoTrack] = stream.getVideoTracks();
    if (audioTrack) {
      await this.localAudioTransceiver.sender.replaceTrack(audioTrack);
      this.localAudioTransceiver.direction = 'sendrecv';
    }
    if (videoTrack) {
      await this.localVideoTransceiver.sender.replaceTrack(videoTrack);
      this.localVideoTransceiver.direction = 'sendrecv';
    }
    await this.renegotiate_();
  }

  async stopSending_() {
    await this.localAudioTransceiver.sender.replaceTrack(null);
    this.localAudioTransceiver.direction = 'inactive';
    await this.localVideoTransceiver.sender.replaceTrack(null);
    this.localVideoTransceiver.direction = 'inactive';
    await this.renegotiate_();
  }

  async renegotiate_() {
    // Implicitly creates the offer.
    await this.localConnection.setLocalDescription();
    await this.remoteConnection.setRemoteDescription(
        this.localConnection.localDescription);
    // Implicitly creates the answer.
    await this.remoteConnection.setLocalDescription();
    await this.localConnection.setRemoteDescription(
        this.remoteConnection.localDescription);
  };

  onIceCandidate_(connection, event) {
    if (event.candidate) {
      connection.addIceCandidate(new RTCIceCandidate(event.candidate));
    }
  };
}

/**
 * Checks if a value is within an expected value plus/minus a delta.
 * @param {number} actual
 * @param {number} expected
 * @param {number} delta
 * @return {boolean}
 */
function isWithin(actual, expected, delta) {
  return actual <= expected + delta && actual >= actual - delta;
}

/**
 * Creates constraints for use with GetUserMedia.
 * @param {!{x: number, y: number}} widthAndHeight Video resolution.
 */
function createMediaConstraints(widthAndHeight) {
  let constraint;
  if (widthAndHeight.w < 0) {
    constraint = false;
  } else {
    constraint = {
      width: {exact: widthAndHeight.w},
      height: {exact: widthAndHeight.h}
    };
  }
  return {
    audio: true,
    video: constraint
  };
}

function resStr(width, height) {
  return `${width}x${height}`
}

function logError(err) {
  console.error(err);
}