• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright 2017 The Chromium Authors. All rights reserved.
3 * Use of this source code is governed by a BSD-style license that can be
4 * found in the LICENSE file.
5 */
6/*jshint esversion: 6 */
7
8/**
9 * A loopback peer connection with one or more streams.
10 */
11class PeerConnection {
12  /**
13   * Creates a loopback peer connection. One stream per supplied resolution is
14   * created.
15   * @param {!Element} videoElement the video element to render the feed on.
16   * @param {!Array<!{x: number, y: number}>} resolutions. A width of -1 will
17   *     result in disabled video for that stream.
18   * @param {?boolean=} cpuOveruseDetection Whether to enable
19   *     googCpuOveruseDetection (lower video quality if CPU usage is high).
20   *     Default is null which means that the constraint is not set at all.
21   */
22  constructor(videoElement, resolutions, cpuOveruseDetection=null) {
23    this.localConnection = null;
24    this.remoteConnection = null;
25    this.remoteView = videoElement;
26    this.streams = [];
27    // Ensure sorted in descending order to conveniently request the highest
28    // resolution first through GUM later.
29    this.resolutions = resolutions.slice().sort((x, y) => y.w - x.w);
30    this.activeStreamIndex = resolutions.length - 1;
31    this.badResolutionsSeen = 0;
32    if (cpuOveruseDetection !== null) {
33      this.pcConstraints = {
34        'optional': [{'googCpuOveruseDetection': cpuOveruseDetection}]
35      };
36    }
37    this.rtcConfig = {'sdpSemantics': 'plan-b'};
38  }
39
40  /**
41   * Starts the connections. Triggers GetUserMedia and starts
42   * to render the video on {@code this.videoElement}.
43   * @return {!Promise} a Promise that resolves when everything is initalized.
44   */
45  start() {
46    // getUserMedia fails if we first request a low resolution and
47    // later a higher one. Hence, sort resolutions above and
48    // start with the highest resolution here.
49    const promises = this.resolutions.map((resolution) => {
50      const constraints = createMediaConstraints(resolution);
51      return navigator.mediaDevices
52        .getUserMedia(constraints)
53        .then((stream) => this.streams.push(stream));
54    });
55    return Promise.all(promises).then(() => {
56      // Start with the smallest video to not overload the machine instantly.
57      return this.onGetUserMediaSuccess_(this.streams[this.activeStreamIndex]);
58    })
59  };
60
61  /**
62   * Verifies that the state of the streams are good. The state is good if all
63   * streams are active and their video elements report the resolution the
64   * stream is in. Video elements are allowed to report bad resolutions
65   * numSequentialBadResolutionsForFailure times before failure is reported
66   * since video elements occasionally report bad resolutions during the tests
67   * when we manipulate the streams frequently.
68   * @param {number=} numSequentialBadResolutionsForFailure number of bad
69   *     resolution observations in a row before failure is reported.
70   * @param {number=} allowedDelta allowed difference between expected and
71   *     actual resolution. We have seen videos assigned a resolution one pixel
72   *     off from the requested.
73   * @throws {Error} in case the state is not-good.
74   */
75  verifyState(numSequentialBadResolutionsForFailure=10, allowedDelta=1) {
76    this.verifyAllStreamsActive_();
77    const expectedResolution = this.resolutions[this.activeStreamIndex];
78    if (expectedResolution.w < 0 || expectedResolution.h < 0) {
79      // Video is disabled.
80      return;
81    }
82    if (!isWithin(
83            this.remoteView.videoWidth, expectedResolution.w, allowedDelta) ||
84        !isWithin(
85            this.remoteView.videoHeight, expectedResolution.h, allowedDelta)) {
86      this.badResolutionsSeen++;
87    } else if (
88        this.badResolutionsSeen < numSequentialBadResolutionsForFailure) {
89      // Reset the count, but only if we have not yet reached the limit. If the
90      // limit is reached, let keep the error state.
91      this.badResolutionsSeen = 0;
92    }
93    if (this.badResolutionsSeen >= numSequentialBadResolutionsForFailure) {
94      throw new Error(
95          'Expected video resolution ' +
96          resStr(expectedResolution.w, expectedResolution.h) +
97          ' but got another resolution ' + this.badResolutionsSeen +
98          ' consecutive times. Last resolution was: ' +
99          resStr(this.remoteView.videoWidth, this.remoteView.videoHeight));
100    }
101  }
102
103  verifyAllStreamsActive_() {
104    if (this.streams.some((x) => !x.active)) {
105      throw new Error('At least one media stream is not active')
106    }
107  }
108
109  /**
110   * Switches to a random stream, i.e., use a random resolution of the
111   * resolutions provided to the constructor.
112   * @return {!Promise} A promise that resolved when everything is initialized.
113   */
114  switchToRandomStream() {
115    const localStreams = this.localConnection.getLocalStreams();
116    const track = localStreams[0];
117    if (track != null) {
118      this.localConnection.removeStream(track);
119      const newStreamIndex = Math.floor(Math.random() * this.streams.length);
120      return this.addStream_(this.streams[newStreamIndex])
121          .then(() => this.activeStreamIndex = newStreamIndex);
122    } else {
123      return Promise.resolve();
124    }
125  }
126
127  onGetUserMediaSuccess_(stream) {
128    this.localConnection = new RTCPeerConnection(this.rtcConfig,
129      this.pcConstraints);
130    this.localConnection.onicecandidate = (event) => {
131      this.onIceCandidate_(this.remoteConnection, event);
132    };
133    this.remoteConnection = new RTCPeerConnection(this.rtcConfig,
134      this.pcConstraints);
135    this.remoteConnection.onicecandidate = (event) => {
136      this.onIceCandidate_(this.localConnection, event);
137    };
138    this.remoteConnection.onaddstream = (e) => {
139      this.remoteView.srcObject = e.stream;
140    };
141    return this.addStream_(stream);
142  }
143
144  addStream_(stream) {
145    this.localConnection.addStream(stream);
146    return this.localConnection
147        .createOffer({offerToReceiveAudio: 1, offerToReceiveVideo: 1})
148        .then((desc) => this.onCreateOfferSuccess_(desc), logError);
149  }
150
151  onCreateOfferSuccess_(desc) {
152    this.localConnection.setLocalDescription(desc);
153    this.remoteConnection.setRemoteDescription(desc);
154    return this.remoteConnection.createAnswer().then(
155        (desc) => this.onCreateAnswerSuccess_(desc), logError);
156  };
157
158  onCreateAnswerSuccess_(desc) {
159    this.remoteConnection.setLocalDescription(desc);
160    this.localConnection.setRemoteDescription(desc);
161  };
162
163  onIceCandidate_(connection, event) {
164    if (event.candidate) {
165      connection.addIceCandidate(new RTCIceCandidate(event.candidate));
166    }
167  };
168}
169
170/**
171 * Checks if a value is within an expected value plus/minus a delta.
172 * @param {number} actual
173 * @param {number} expected
174 * @param {number} delta
175 * @return {boolean}
176 */
177function isWithin(actual, expected, delta) {
178  return actual <= expected + delta && actual >= actual - delta;
179}
180
181/**
182 * Creates constraints for use with GetUserMedia.
183 * @param {!{x: number, y: number}} widthAndHeight Video resolution.
184 */
185function createMediaConstraints(widthAndHeight) {
186  let constraint;
187  if (widthAndHeight.w < 0) {
188    constraint = false;
189  } else {
190    constraint = {
191      width: {exact: widthAndHeight.w},
192      height: {exact: widthAndHeight.h}
193    };
194  }
195  return {
196    audio: true,
197    video: constraint
198  };
199}
200
201function resStr(width, height) {
202  return `${width}x${height}`
203}
204
205function logError(err) {
206  console.error(err);
207}
208