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  }
38
39  /**
40   * Starts the connections. Triggers GetUserMedia and starts
41   * to render the video on {@code this.videoElement}.
42   * @return {!Promise} a Promise that resolves when everything is initalized.
43   */
44  start() {
45    // getUserMedia fails if we first request a low resolution and
46    // later a higher one. Hence, sort resolutions above and
47    // start with the highest resolution here.
48    const promises = this.resolutions.map((resolution) => {
49      const constraints = createMediaConstraints(resolution);
50      return navigator.mediaDevices
51        .getUserMedia(constraints)
52        .then((stream) => this.streams.push(stream));
53    });
54    return Promise.all(promises).then(() => {
55      // Start with the smallest video to not overload the machine instantly.
56      return this.onGetUserMediaSuccess_(this.streams[this.activeStreamIndex]);
57    })
58  };
59
60  /**
61   * Verifies that the state of the streams are good. The state is good if all
62   * streams are active and their video elements report the resolution the
63   * stream is in. Video elements are allowed to report bad resolutions
64   * numSequentialBadResolutionsForFailure times before failure is reported
65   * since video elements occasionally report bad resolutions during the tests
66   * when we manipulate the streams frequently.
67   * @param {number=} numSequentialBadResolutionsForFailure number of bad
68   *     resolution observations in a row before failure is reported.
69   * @param {number=} allowedDelta allowed difference between expected and
70   *     actual resolution. We have seen videos assigned a resolution one pixel
71   *     off from the requested.
72   * @throws {Error} in case the state is not-good.
73   */
74  verifyState(numSequentialBadResolutionsForFailure=10, allowedDelta=1) {
75    this.verifyAllStreamsActive_();
76    const expectedResolution = this.resolutions[this.activeStreamIndex];
77    if (expectedResolution.w < 0 || expectedResolution.h < 0) {
78      // Video is disabled.
79      return;
80    }
81    if (!isWithin(
82            this.remoteView.videoWidth, expectedResolution.w, allowedDelta) ||
83        !isWithin(
84            this.remoteView.videoHeight, expectedResolution.h, allowedDelta)) {
85      this.badResolutionsSeen++;
86    } else if (
87        this.badResolutionsSeen < numSequentialBadResolutionsForFailure) {
88      // Reset the count, but only if we have not yet reached the limit. If the
89      // limit is reached, let keep the error state.
90      this.badResolutionsSeen = 0;
91    }
92    if (this.badResolutionsSeen >= numSequentialBadResolutionsForFailure) {
93      throw new Error(
94          'Expected video resolution ' +
95          resStr(expectedResolution.w, expectedResolution.h) +
96          ' but got another resolution ' + this.badResolutionsSeen +
97          ' consecutive times. Last resolution was: ' +
98          resStr(this.remoteView.videoWidth, this.remoteView.videoHeight));
99    }
100  }
101
102  verifyAllStreamsActive_() {
103    if (this.streams.some((x) => !x.active)) {
104      throw new Error('At least one media stream is not active')
105    }
106  }
107
108  /**
109   * Switches to a random stream, i.e., use a random resolution of the
110   * resolutions provided to the constructor.
111   * @return {!Promise} A promise that resolved when everything is initialized.
112   */
113  switchToRandomStream() {
114    const localStreams = this.localConnection.getLocalStreams();
115    const track = localStreams[0];
116    if (track != null) {
117      this.localConnection.removeStream(track);
118      const newStreamIndex = Math.floor(Math.random() * this.streams.length);
119      return this.addStream_(this.streams[newStreamIndex])
120          .then(() => this.activeStreamIndex = newStreamIndex);
121    } else {
122      return Promise.resolve();
123    }
124  }
125
126  onGetUserMediaSuccess_(stream) {
127    this.localConnection = new RTCPeerConnection(null, this.pcConstraints);
128    this.localConnection.onicecandidate = (event) => {
129      this.onIceCandidate_(this.remoteConnection, event);
130    };
131    this.remoteConnection = new RTCPeerConnection(null, this.pcConstraints);
132    this.remoteConnection.onicecandidate = (event) => {
133      this.onIceCandidate_(this.localConnection, event);
134    };
135    this.remoteConnection.onaddstream = (e) => {
136      this.remoteView.srcObject = e.stream;
137    };
138    return this.addStream_(stream);
139  }
140
141  addStream_(stream) {
142    this.localConnection.addStream(stream);
143    return this.localConnection
144        .createOffer({offerToReceiveAudio: 1, offerToReceiveVideo: 1})
145        .then((desc) => this.onCreateOfferSuccess_(desc), logError);
146  }
147
148  onCreateOfferSuccess_(desc) {
149    this.localConnection.setLocalDescription(desc);
150    this.remoteConnection.setRemoteDescription(desc);
151    return this.remoteConnection.createAnswer().then(
152        (desc) => this.onCreateAnswerSuccess_(desc), logError);
153  };
154
155  onCreateAnswerSuccess_(desc) {
156    this.remoteConnection.setLocalDescription(desc);
157    this.localConnection.setRemoteDescription(desc);
158  };
159
160  onIceCandidate_(connection, event) {
161    if (event.candidate) {
162      connection.addIceCandidate(new RTCIceCandidate(event.candidate));
163    }
164  };
165}
166
167/**
168 * Checks if a value is within an expected value plus/minus a delta.
169 * @param {number} actual
170 * @param {number} expected
171 * @param {number} delta
172 * @return {boolean}
173 */
174function isWithin(actual, expected, delta) {
175  return actual <= expected + delta && actual >= actual - delta;
176}
177
178/**
179 * Creates constraints for use with GetUserMedia.
180 * @param {!{x: number, y: number}} widthAndHeight Video resolution.
181 */
182function createMediaConstraints(widthAndHeight) {
183  let constraint;
184  if (widthAndHeight.w < 0) {
185    constraint = false;
186  } else {
187    constraint = {
188      width: {exact: widthAndHeight.w},
189      height: {exact: widthAndHeight.h}
190    };
191  }
192  return {
193    audio: true,
194    video: constraint
195  };
196}
197
198function resStr(width, height) {
199  return `${width}x${height}`
200}
201
202function logError(err) {
203  console.error(err);
204}
205