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