ep_webrtc 2.1.0 → 2.2.1

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 +4 -4
  2. package/static/js/index.js +66 -60
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.1.0",
8
+ "version": "2.2.1",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -15,15 +15,15 @@
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",
22
22
  "url": "https://etherpad.org/"
23
23
  },
24
24
  "devDependencies": {
25
- "eslint": "^8.10.0",
26
- "eslint-config-etherpad": "^3.0.5",
25
+ "eslint": "^8.11.0",
26
+ "eslint-config-etherpad": "^3.0.9",
27
27
  "typescript": "^4.6.2"
28
28
  },
29
29
  "scripts": {
@@ -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);
@@ -163,45 +163,42 @@ class PeerState extends EventTargetPolyfill {
163
163
  };
164
164
  this._closed = false;
165
165
  this._pc = null;
166
+ this._xcvrs = {};
166
167
  this._remoteStream = null;
167
- this._onremovetrack =
168
- () => { if (this._remoteStream.getTracks().length === 0) this._setRemoteStream(null); };
169
168
  this._ontrackchanged = async ({oldTrack, newTrack}) => {
170
- if (this._pc == null || this._pc.connectionState === 'closed') return;
171
- this._debug(`replacing ${oldTrack ? oldTrack.kind : newTrack.kind} track ` +
172
- `${oldTrack ? oldTrack.id : '(null)'} with ` +
173
- `${newTrack ? newTrack.id : '(null)'}`);
174
- if (oldTrack != null) {
175
- for (const sender of this._pc.getSenders()) {
176
- if (sender.track !== oldTrack) continue;
177
- if (newTrack != null) {
178
- try {
179
- return await sender.replaceTrack(newTrack);
180
- } catch (err) {
181
- this._debug('renegotiation is required');
182
- logErrorToServer(err);
183
- }
184
- }
185
- this._pc.removeTrack(sender);
186
- break;
187
- }
188
- }
189
- if (newTrack != null) this._pc.addTrack(newTrack, this._localTracks.stream);
169
+ const {kind} = oldTrack || newTrack;
170
+ await this._sendLocalTrack(kind, newTrack);
190
171
  };
191
172
  this._localTracks.addEventListener('trackchanged', this._ontrackchanged);
192
173
  }
193
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
+
194
193
  _setRemoteStream(stream) {
195
194
  if (stream === this._remoteStream) return;
196
195
  if (this._remoteStream != null) {
197
196
  const oldStream = this._remoteStream;
198
- oldStream.removeEventListener('removetrack', this._onremovetrack);
199
197
  this._remoteStream = null;
200
198
  this.dispatchEvent(new StreamEvent('streamgone', oldStream));
201
199
  }
202
200
  if (stream != null) {
203
201
  this._remoteStream = stream;
204
- stream.addEventListener('removetrack', this._onremovetrack);
205
202
  this.dispatchEvent(new StreamEvent('stream', stream));
206
203
  }
207
204
  }
@@ -221,21 +218,19 @@ class PeerState extends EventTargetPolyfill {
221
218
  this._ids.from.instance = ++nextInstanceId;
222
219
  this._ids.to = peerIds;
223
220
  const pc = new RTCPeerConnection(this._pcConfig);
224
- pc.addEventListener('track', ({track, streams}) => {
225
- if (streams.length !== 1) throw new Error('Expected track to be in exactly one stream');
226
- 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
+ }
227
231
  });
228
232
  pc.addEventListener('icecandidate', ({candidate}) => this._sendMessage({candidate}));
229
233
  pc.addEventListener('negotiationneeded', async () => {
230
- if (!this._caller) {
231
- this._debug('Waiting for peer to call');
232
- // It is possible that the last invite sent to the peer was sent before the peer was ready
233
- // to accept invites. If that is the case and there are no local tracks to trigger a WebRTC
234
- // message exchange, the peer won't know that it is OK to connect back. Send another invite
235
- // just in case. The peer will ignore any superfluous invites.
236
- this._sendMessage({invite: 'invite'});
237
- return;
238
- }
239
234
  try {
240
235
  await pc.setLocalDescription();
241
236
  this._failedSLDAttempts = 0;
@@ -292,11 +287,32 @@ class PeerState extends EventTargetPolyfill {
292
287
 
293
288
  if (this._pc != null) this._pc.close();
294
289
  this._pc = pc;
295
-
296
- const tracks = this._localTracks.stream.getTracks();
297
- for (const track of tracks) {
298
- this._debug(`start streaming ${track.kind} track ${track.id}`);
299
- 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'});
300
316
  }
301
317
  }
302
318
 
@@ -386,17 +402,7 @@ exports.rtc = new class {
386
402
  constructor() {
387
403
  this._activated = null;
388
404
  this._settings = null;
389
- this._disabledSilence = (() => {
390
- const ctx = new AudioContext();
391
- const gain = ctx.createGain();
392
- const dst = gain.connect(ctx.createMediaStreamDestination());
393
- const track = dst.stream.getAudioTracks()[0];
394
- track.enabled = false;
395
- return track;
396
- })();
397
- this._blankStream = new MediaStream([this._disabledSilence]);
398
405
  this._localTracks = new LocalTracks();
399
- this._localTracks.setTrack(this._disabledSilence.kind, this._disabledSilence);
400
406
  this._localTracks.addEventListener('trackchanged', ({oldTrack, newTrack}) => {
401
407
  // Normally the self-view UI only needs to be updated if the user clicks on something, but it
402
408
  // also needs to be updated if the browser decides to end the local stream for whatever
@@ -621,8 +627,7 @@ exports.rtc = new class {
621
627
  try {
622
628
  const devices = [];
623
629
  const addAudioTrack = updateAudio && this._selfViewButtons.audio.enabled &&
624
- !this._localTracks.stream.getAudioTracks().some(
625
- (t) => t !== this._disabledSilence && t.readyState === 'live');
630
+ !this._localTracks.stream.getAudioTracks().some((t) => t.readyState === 'live');
626
631
  if (addAudioTrack) devices.push('mic');
627
632
  const addVideoTrack = updateVideo && this._selfViewButtons.video.enabled &&
628
633
  (!this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live') ||
@@ -675,7 +680,7 @@ exports.rtc = new class {
675
680
  for (const track of this._localTracks.stream.getAudioTracks()) {
676
681
  // Re-check the state of the button because the user might have clicked it while
677
682
  // getUserMedia() was running.
678
- track.enabled = track !== this._disabledSilence && this._selfViewButtons.audio.enabled;
683
+ track.enabled = this._selfViewButtons.audio.enabled;
679
684
  }
680
685
  const hasAudio = this._localTracks.stream.getAudioTracks().some(
681
686
  (t) => t.enabled && t.readyState === 'live');
@@ -881,8 +886,9 @@ exports.rtc = new class {
881
886
  minWidth: iw > width ? `${iw}px` : '',
882
887
  minHeight: nh + ih > height ? `${nh + ih}px` : '',
883
888
  });
884
- })
885
- .appendTo($('#rtcbox'));
889
+ });
890
+ if (isLocal) $videoContainer.prependTo($('#rtcbox'));
891
+ else $videoContainer.appendTo($('#rtcbox'));
886
892
  this.updatePeerNameAndColor(this.getUserFromId(userId));
887
893
 
888
894
  // For tests it is important to know when an asynchronous event handler has finishing handling
@@ -1279,7 +1285,7 @@ exports.rtc = new class {
1279
1285
  logDisconnectErrorTimeout =
1280
1286
  setTimeout(() => logErrorToServer(new Error('RTC connection lost'), 0), 10000);
1281
1287
  ($videoContainer.data('updateMinSize') || (() => {}))();
1282
- await this.setStream(userId, this._blankStream);
1288
+ await this.setStream(userId, new MediaStream());
1283
1289
  });
1284
1290
  peer.addEventListener('closed', () => {
1285
1291
  _debug('PeerState closed');