ep_webrtc 2.0.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/static/js/index.js +83 -113
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:ether/ep_webrtc.git",
6
6
  "type": "git"
7
7
  },
8
- "version": "2.0.4",
8
+ "version": "2.2.0",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "abort-controller": "^3.0.0",
17
17
  "lodash": "^4.17.21",
18
- "node-fetch": "^3.2.1"
18
+ "node-fetch": "^3.2.3"
19
19
  },
20
20
  "funding": {
21
21
  "type": "individual",
@@ -87,7 +87,7 @@ class LocalTracks extends EventTargetPolyfill {
87
87
  this.videoIsScreenshare = false;
88
88
  }
89
89
 
90
- _getTracks(kind) {
90
+ getTracks(kind) {
91
91
  return kind === 'audio' ? this.stream.getAudioTracks()
92
92
  : kind === 'video' ? this.stream.getVideoTracks()
93
93
  : this.stream.getTracks();
@@ -96,7 +96,7 @@ class LocalTracks extends EventTargetPolyfill {
96
96
  setTrack(kind, newTrack) {
97
97
  newTrack = newTrack || null; // Convert undefined to null.
98
98
  let oldTrack = null;
99
- const tracks = this._getTracks(kind);
99
+ const tracks = this.getTracks(kind);
100
100
  for (const track of tracks) {
101
101
  if (track.kind !== kind) continue;
102
102
  if (track === newTrack) return; // No change.
@@ -109,7 +109,7 @@ class LocalTracks extends EventTargetPolyfill {
109
109
  debug(`adding ${kind} track ${newTrack.id} to local stream`);
110
110
  newTrack.addEventListener('ended', () => {
111
111
  debug(`local ${kind} track ${newTrack.id} ended`);
112
- if (!this._getTracks(kind).includes(newTrack)) return;
112
+ if (!this.getTracks(kind).includes(newTrack)) return;
113
113
  this.setTrack(kind, null);
114
114
  });
115
115
  this.stream.addTrack(newTrack);
@@ -135,10 +135,6 @@ class ClosedEvent extends CustomEvent {
135
135
  }
136
136
  }
137
137
 
138
- // The WebRTC negotiation logic used here is based on the "Perfect Negotiation Example" at
139
- // https://www.w3.org/TR/2021/REC-webrtc-20210126/#perfect-negotiation-example. See there for
140
- // details about how it works.
141
- //
142
138
  // Events:
143
139
  // * 'stream' (see StreamEvent): Emitted when the remote stream is ready. For every 'stream' event
144
140
  // there will be a corresponding 'streamgone' event. Once a stream is added another stream will
@@ -148,15 +144,15 @@ class ClosedEvent extends CustomEvent {
148
144
  // * 'closed' (see ClosedEvent): Emitted when the PeerState is closed, except when closed by a
149
145
  // call to close(). The PeerState must not be used after it is closed.
150
146
  class PeerState extends EventTargetPolyfill {
151
- constructor(pcConfig, polite, sendMessage, localTracks, debug) {
147
+ constructor(pcConfig, caller, sendMessage, localTracks, debug) {
152
148
  super();
153
149
  this._pcConfig = pcConfig;
154
- this._polite = polite;
150
+ this._caller = caller;
155
151
  this._sendMessage = (msg) => sendMessage(Object.assign({ids: this._ids}, msg));
156
152
  this._localTracks = localTracks;
157
153
  this._failedSLDAttempts = 0;
158
154
  this._debug = debug;
159
- this._debug(`I am the ${this._polite ? '' : 'im'}polite peer`);
155
+ this._debug(`I am the ${this._caller ? 'calling' : 'answering'} peer`);
160
156
  this._ids = {
161
157
  from: {
162
158
  // Only changes when the user reloads the page.
@@ -167,45 +163,42 @@ class PeerState extends EventTargetPolyfill {
167
163
  };
168
164
  this._closed = false;
169
165
  this._pc = null;
166
+ this._xcvrs = {};
170
167
  this._remoteStream = null;
171
- this._onremovetrack =
172
- () => { if (this._remoteStream.getTracks().length === 0) this._setRemoteStream(null); };
173
168
  this._ontrackchanged = async ({oldTrack, newTrack}) => {
174
- if (this._pc == null || this._pc.connectionState === 'closed') return;
175
- this._debug(`replacing ${oldTrack ? oldTrack.kind : newTrack.kind} track ` +
176
- `${oldTrack ? oldTrack.id : '(null)'} with ` +
177
- `${newTrack ? newTrack.id : '(null)'}`);
178
- if (oldTrack != null) {
179
- for (const sender of this._pc.getSenders()) {
180
- if (sender.track !== oldTrack) continue;
181
- if (newTrack != null) {
182
- try {
183
- return await sender.replaceTrack(newTrack);
184
- } catch (err) {
185
- this._debug('renegotiation is required');
186
- logErrorToServer(err);
187
- }
188
- }
189
- this._pc.removeTrack(sender);
190
- break;
191
- }
192
- }
193
- if (newTrack != null) this._pc.addTrack(newTrack, this._localTracks.stream);
169
+ const {kind} = oldTrack || newTrack;
170
+ await this._sendLocalTrack(kind, newTrack);
194
171
  };
195
172
  this._localTracks.addEventListener('trackchanged', this._ontrackchanged);
196
173
  }
197
174
 
175
+ async _sendLocalTrack(kind, track) {
176
+ if (this._pc == null || this._pc.connectionState === 'closed') return;
177
+ const {sender: {track: old} = {}} = this._xcvrs[kind] || {};
178
+ if ((old == null && track == null) || old === track) return;
179
+ this._debug(
180
+ `replacing ${kind} track ${old ? old.id : '(null)'} with ${track ? track.id : '(null)'}`);
181
+ const xcvr = this._xcvrs[kind];
182
+ if (!xcvr) return;
183
+ try {
184
+ if (track != null) xcvr.direction = 'sendrecv'; // Will throw if not already bidirectional.
185
+ await xcvr.sender.replaceTrack(track);
186
+ } catch (err) {
187
+ this._debug('renegotiation is required');
188
+ this._resetConnection(err);
189
+ return;
190
+ }
191
+ }
192
+
198
193
  _setRemoteStream(stream) {
199
194
  if (stream === this._remoteStream) return;
200
195
  if (this._remoteStream != null) {
201
196
  const oldStream = this._remoteStream;
202
- oldStream.removeEventListener('removetrack', this._onremovetrack);
203
197
  this._remoteStream = null;
204
198
  this.dispatchEvent(new StreamEvent('streamgone', oldStream));
205
199
  }
206
200
  if (stream != null) {
207
201
  this._remoteStream = stream;
208
- stream.addEventListener('removetrack', this._onremovetrack);
209
202
  this.dispatchEvent(new StreamEvent('stream', stream));
210
203
  }
211
204
  }
@@ -224,24 +217,21 @@ class PeerState extends EventTargetPolyfill {
224
217
  this._setRemoteStream(null);
225
218
  this._ids.from.instance = ++nextInstanceId;
226
219
  this._ids.to = peerIds;
227
- // This negotiation state is captured in closures (instead of doing something like
228
- // this._negotiationState) because this._resetConnection() needs to be sure that all of the
229
- // event handlers for the old RTCPeerConnection do not mutate the negotiation state for the new
230
- // RTCPeerConnection.
231
- const negotiationState = {
232
- makingOffer: false,
233
- ignoreOffer: false,
234
- isSettingRemoteAnswerPending: false,
235
- };
236
220
  const pc = new RTCPeerConnection(this._pcConfig);
237
- pc.addEventListener('track', ({track, streams}) => {
238
- if (streams.length !== 1) throw new Error('Expected track to be in exactly one stream');
239
- this._setRemoteStream(streams[0]);
221
+ pc.addEventListener('track', async ({track, transceiver}) => {
222
+ this._debug(`Received ${track.kind} track from peer, ID: ${track.id}`);
223
+ const stream = this._remoteStream || new MediaStream();
224
+ stream.addTrack(track);
225
+ this._setRemoteStream(stream);
226
+ if (!this._caller) {
227
+ this._xcvrs[track.kind] = transceiver;
228
+ // _sendLocalTrack might call _resetConnection, so it should be called last.
229
+ await this._sendLocalTrack(track.kind, this._localTracks.getTracks(track.kind)[0]);
230
+ }
240
231
  });
241
232
  pc.addEventListener('icecandidate', ({candidate}) => this._sendMessage({candidate}));
242
233
  pc.addEventListener('negotiationneeded', async () => {
243
234
  try {
244
- negotiationState.makingOffer = true;
245
235
  await pc.setLocalDescription();
246
236
  this._failedSLDAttempts = 0;
247
237
  this._sendMessage({description: pc.localDescription});
@@ -250,8 +240,6 @@ class PeerState extends EventTargetPolyfill {
250
240
  if (++this._failedSLDAttempts > 10) throw err; // Avoid an infinite loop.
251
241
  this._resetConnection(err);
252
242
  return;
253
- } finally {
254
- negotiationState.makingOffer = false;
255
243
  }
256
244
  });
257
245
  pc.addEventListener('connectionstatechange', () => {
@@ -299,46 +287,33 @@ class PeerState extends EventTargetPolyfill {
299
287
 
300
288
  if (this._pc != null) this._pc.close();
301
289
  this._pc = pc;
302
- this._setRemoteDescription = async (description) => {
303
- const readyForOffer = !negotiationState.makingOffer &&
304
- (pc.signalingState === 'stable' || negotiationState.isSettingRemoteAnswerPending);
305
- const offerCollision = description.type === 'offer' && !readyForOffer;
306
- negotiationState.ignoreOffer = !this._polite && offerCollision;
307
- if (negotiationState.ignoreOffer) {
308
- this._debug('ignoring offer due to offer collision');
309
- return;
310
- }
311
- negotiationState.isSettingRemoteAnswerPending = description.type === 'answer';
312
- await pc.setRemoteDescription(description);
313
- // The "Perfect Negotiation Example" doesn't put this next line inside a `finally` block. It
314
- // is unclear whether that is intentional. Fortunately it doesn't matter here: If the above
315
- // pc.setRemoteDescription() call throws, _resetConnection() is called to restart the
316
- // negotiation anyway.
317
- negotiationState.isSettingRemoteAnswerPending = false;
318
- if (description.type === 'offer') {
319
- await pc.setLocalDescription();
320
- this._sendMessage({description: pc.localDescription});
321
- }
322
- };
323
- this._addIceCandidate = async (candidate) => {
324
- try {
325
- await pc.addIceCandidate(candidate);
326
- } catch (err) {
327
- if (!negotiationState.ignoreOffer) throw err;
328
- }
329
- };
330
-
331
- const tracks = this._localTracks.stream.getTracks();
332
- for (const track of tracks) {
333
- this._debug(`start streaming ${track.kind} track ${track.id}`);
334
- pc.addTrack(track, this._localTracks.stream);
290
+ this._xcvrs = {};
291
+
292
+ if (this._caller) {
293
+ // Adding transceivers triggers negotiation with the peer, which we want to do as soon as
294
+ // possible to warm up the peer connection (ICE takes a while). Adding a track implicitly adds
295
+ // a transceiver so we could do that instead, but:
296
+ //
297
+ // * We might not have a local track yet (maybe the user hasn't yet allowed access to the
298
+ // camera/mic).
299
+ // * Using a dummy silence track until a real track is ready might result in two
300
+ // unidirectional SDP m-lines (one in each direction) instead of a single bidirectional
301
+ // m-line. This seems to trigger an echo bug in Safari.
302
+ //
303
+ // Using transceivers to warm up the connection is the approach taken in:
304
+ // https://www.w3.org/TR/2021/REC-webrtc-20210126/#advanced-peer-to-peer-example-with-warm-up
305
+ // Also see: https://webrtcstandards.info/ice-warm-up-m-line-webrtc-transceivers/
306
+ Promise.all(['audio', 'video'].map(async (t) => {
307
+ this._debug(`adding ${t} transceiver`);
308
+ this._xcvrs[t] = this._pc.addTransceiver(t);
309
+ await this._sendLocalTrack(t, this._localTracks.getTracks(t)[0]);
310
+ }));
311
+ } else {
312
+ // It is possible that the last invite sent to the peer was sent before the peer was ready to
313
+ // accept invites, so the peer might not know that it should call now. Send another invite
314
+ // just in case. The peer will ignore any superfluous invites.
315
+ this._sendMessage({invite: 'invite'});
335
316
  }
336
- // Creating an RTCPeerConnection doesn't actually generate any control messages until
337
- // RTCPeerConnection.addTrack() is called. It is possible that the last invite sent to the peer
338
- // was sent before the peer was ready to accept invites. If that is the case and there are no
339
- // local tracks to trigger a WebRTC message exchange, the peer won't know that it is OK to
340
- // connect back. Send another invite just in case. The peer will ignore any superfluous invites.
341
- if (tracks.length === 0) this._sendMessage({invite: 'invite'});
342
317
  }
343
318
 
344
319
  async receiveMessage(msg) {
@@ -374,8 +349,14 @@ class PeerState extends EventTargetPolyfill {
374
349
  }
375
350
  if (this._pc == null) this._resetConnection(null, this._ids.to);
376
351
  try {
377
- if (description != null) await this._setRemoteDescription(description);
378
- if (candidate != null) await this._addIceCandidate(candidate);
352
+ if (description != null) {
353
+ await this._pc.setRemoteDescription(description);
354
+ if (description.type === 'offer') {
355
+ await this._pc.setLocalDescription();
356
+ this._sendMessage({description: this._pc.localDescription});
357
+ }
358
+ }
359
+ if (candidate != null) await this._pc.addIceCandidate(candidate);
379
360
  } catch (err) {
380
361
  err.peerMessage = msg;
381
362
  console.error('Error processing message from peer:', err);
@@ -396,14 +377,13 @@ class PeerState extends EventTargetPolyfill {
396
377
  }
397
378
  }
398
379
 
399
- const isPolite = (myId, otherId) => {
400
- // Compare user IDs to determine which of the two users is the "polite" user.
401
- const polite = myId.localeCompare(otherId) < 0;
402
- if ((otherId.localeCompare(myId) < 0) === polite) {
403
- // One peer must be polite and the other must be impolite.
380
+ const isCaller = (myId, otherId) => {
381
+ // Compare user IDs to determine which of the two users will initiate the call.
382
+ const caller = myId.localeCompare(otherId) < 0;
383
+ if ((otherId.localeCompare(myId) < 0) === caller) {
404
384
  throw new Error(`Peer ID ${otherId} compares equivalent to own ID ${myId}`);
405
385
  }
406
- return polite;
386
+ return caller;
407
387
  };
408
388
 
409
389
  // Periods in element IDs make it hard to build a selector string because period is for class match.
@@ -422,17 +402,7 @@ exports.rtc = new class {
422
402
  constructor() {
423
403
  this._activated = null;
424
404
  this._settings = null;
425
- this._disabledSilence = (() => {
426
- const ctx = new AudioContext();
427
- const gain = ctx.createGain();
428
- const dst = gain.connect(ctx.createMediaStreamDestination());
429
- const track = dst.stream.getAudioTracks()[0];
430
- track.enabled = false;
431
- return track;
432
- })();
433
- this._blankStream = new MediaStream([this._disabledSilence]);
434
405
  this._localTracks = new LocalTracks();
435
- this._localTracks.setTrack(this._disabledSilence.kind, this._disabledSilence);
436
406
  this._localTracks.addEventListener('trackchanged', ({oldTrack, newTrack}) => {
437
407
  // Normally the self-view UI only needs to be updated if the user clicks on something, but it
438
408
  // also needs to be updated if the browser decides to end the local stream for whatever
@@ -657,8 +627,7 @@ exports.rtc = new class {
657
627
  try {
658
628
  const devices = [];
659
629
  const addAudioTrack = updateAudio && this._selfViewButtons.audio.enabled &&
660
- !this._localTracks.stream.getAudioTracks().some(
661
- (t) => t !== this._disabledSilence && t.readyState === 'live');
630
+ !this._localTracks.stream.getAudioTracks().some((t) => t.readyState === 'live');
662
631
  if (addAudioTrack) devices.push('mic');
663
632
  const addVideoTrack = updateVideo && this._selfViewButtons.video.enabled &&
664
633
  (!this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live') ||
@@ -711,7 +680,7 @@ exports.rtc = new class {
711
680
  for (const track of this._localTracks.stream.getAudioTracks()) {
712
681
  // Re-check the state of the button because the user might have clicked it while
713
682
  // getUserMedia() was running.
714
- track.enabled = track !== this._disabledSilence && this._selfViewButtons.audio.enabled;
683
+ track.enabled = this._selfViewButtons.audio.enabled;
715
684
  }
716
685
  const hasAudio = this._localTracks.stream.getAudioTracks().some(
717
686
  (t) => t.enabled && t.readyState === 'live');
@@ -917,8 +886,9 @@ exports.rtc = new class {
917
886
  minWidth: iw > width ? `${iw}px` : '',
918
887
  minHeight: nh + ih > height ? `${nh + ih}px` : '',
919
888
  });
920
- })
921
- .appendTo($('#rtcbox'));
889
+ });
890
+ if (isLocal) $videoContainer.prependTo($('#rtcbox'));
891
+ else $videoContainer.appendTo($('#rtcbox'));
922
892
  this.updatePeerNameAndColor(this.getUserFromId(userId));
923
893
 
924
894
  // For tests it is important to know when an asynchronous event handler has finishing handling
@@ -1292,7 +1262,7 @@ exports.rtc = new class {
1292
1262
  _debug('creating PeerState');
1293
1263
  peer = new PeerState(
1294
1264
  {iceServers: this._settings.iceServers},
1295
- isPolite(this.getUserId(), userId),
1265
+ isCaller(this.getUserId(), userId),
1296
1266
  (msg) => this.sendMessage(userId, msg),
1297
1267
  this._localTracks,
1298
1268
  _debug);
@@ -1315,7 +1285,7 @@ exports.rtc = new class {
1315
1285
  logDisconnectErrorTimeout =
1316
1286
  setTimeout(() => logErrorToServer(new Error('RTC connection lost'), 0), 10000);
1317
1287
  ($videoContainer.data('updateMinSize') || (() => {}))();
1318
- await this.setStream(userId, this._blankStream);
1288
+ await this.setStream(userId, new MediaStream());
1319
1289
  });
1320
1290
  peer.addEventListener('closed', () => {
1321
1291
  _debug('PeerState closed');