1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
content / test / data / media / peerconnection-multiple-streams.js [blame]
/*
* 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);
}