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