ep_webrtc 0.1.77 → 0.1.81

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.
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  # ep_webrtc
4
4
 
5
- WebRTC-based audio/video chat with other users visiting the same pad.
5
+ WebRTC-based audio/video chat and screen sharing with other users visiting the
6
+ same pad.
6
7
 
7
8
  The audio and video streams are peer-to-peer: every user sends a copy of their
8
9
  audio/video streams directly to every other user visiting the same pad. Because
package/locales/en.json CHANGED
@@ -5,9 +5,11 @@
5
5
  "ep_webrtc_error_permission_cam": "Failed to get permission to access your camera.",
6
6
  "ep_webrtc_error_permission_mic": "Failed to get permission to access your microphone.",
7
7
  "ep_webrtc_error_permission_cammic": "Failed to get permission to access your camera and microphone.",
8
+ "ep_webrtc_error_permission_screen": "Failed to get permission to access your screen.",
8
9
  "ep_webrtc_error_notFound_cam": "Failed to access your camera.",
9
10
  "ep_webrtc_error_notFound_mic": "Failed to access your microphone.",
10
11
  "ep_webrtc_error_notFound_cammic": "Failed to access your camera and microphone.",
12
+ "ep_webrtc_error_notFound_screen": "Failed to access your screen.",
11
13
  "ep_webrtc_error_notReadable": "Sorry, a hardware error occurred that prevented access to your camera and/or microphone:",
12
14
  "ep_webrtc_error_otherCantAccess": "Sorry, the browser doesn't have access to your camera and/or microphone. Please check permissions in your browser's settings. This is most likely not a hardware error:",
13
15
 
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": "0.1.77",
8
+ "version": "0.1.81",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
@@ -102,6 +102,12 @@
102
102
  #rtcbox .interface-btn.video-btn:before { content: '\e83b'; }
103
103
  #rtcbox .interface-btn.video-btn.off:before { content: '\e83c'; }
104
104
 
105
+ #rtcbox .interface-btn.screenshare-btn:before {
106
+ content: "\f108";
107
+ font-family: "fontawesome-ep_webrtc";
108
+ }
109
+ #rtcbox .interface-btn.screenshare-btn.off:before { content: "\f109"; }
110
+
105
111
  #rtcbox .interface-btn.enlarge-btn:before { content: '\e840'; }
106
112
  #rtcbox .interface-btn.enlarge-btn.large:before { content: '\e83f'; }
107
113
 
@@ -44,6 +44,30 @@ const sessionId = Date.now();
44
44
  // Incremented each time a new RTCPeerConnection is created.
45
45
  let nextInstanceId = 0;
46
46
 
47
+ const logErrorToServer = async (err, delay = 10000) => {
48
+ // Sleep to avoid logging benign errors caused by the user leaving the page (e.g., audio/video
49
+ // stream ended unexpectedly). If the user navigates away during this sleep the error will not
50
+ // be logged.
51
+ if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
52
+ // Mimick Etherpad core's global exception handler in pad_utils.js.
53
+ const {message = 'unknown', fileName = 'unknown', lineNumber = -1} = err;
54
+ let msg = message;
55
+ if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {
56
+ msg = `${err.name}: ${msg}`;
57
+ }
58
+ await $.post('../jserror', {
59
+ errorInfo: JSON.stringify({
60
+ type: 'Plugin ep_webrtc',
61
+ msg,
62
+ url: window.location.href,
63
+ source: fileName,
64
+ linenumber: lineNumber,
65
+ userAgent: navigator.userAgent,
66
+ stack: err.stack,
67
+ }),
68
+ });
69
+ };
70
+
47
71
  class Mutex {
48
72
  async lock() {
49
73
  while (this._locked != null) await this._locked;
@@ -60,6 +84,7 @@ class LocalTracks extends EventTargetPolyfill {
60
84
  constructor() {
61
85
  super();
62
86
  Object.defineProperty(this, 'stream', {value: new MediaStream(), writeable: false});
87
+ this.videoIsScreenshare = false;
63
88
  }
64
89
 
65
90
  _getTracks(kind) {
@@ -158,6 +183,7 @@ class PeerState extends EventTargetPolyfill {
158
183
  return await sender.replaceTrack(newTrack);
159
184
  } catch (err) {
160
185
  this._debug('renegotiation is required');
186
+ logErrorToServer(err);
161
187
  }
162
188
  }
163
189
  this._pc.removeTrack(sender);
@@ -184,7 +210,11 @@ class PeerState extends EventTargetPolyfill {
184
210
  }
185
211
  }
186
212
 
187
- _resetConnection(peerIds = null) {
213
+ _resetConnection(err = null, peerIds = null) {
214
+ if (err != null) {
215
+ this._debug('resetting connection due to error:', err);
216
+ logErrorToServer(err);
217
+ }
188
218
  if (this._closed) {
189
219
  this._debug('ignoring _resetConnection() on closed PeerState');
190
220
  return;
@@ -217,7 +247,7 @@ class PeerState extends EventTargetPolyfill {
217
247
  } catch (err) {
218
248
  console.error('Error setting local description:', err);
219
249
  if (++this._failedSLDAttempts > 10) throw err; // Avoid an infinite loop.
220
- this._resetConnection();
250
+ this._resetConnection(err);
221
251
  return;
222
252
  } finally {
223
253
  negotiationState.makingOffer = false;
@@ -226,7 +256,9 @@ class PeerState extends EventTargetPolyfill {
226
256
  pc.addEventListener('connectionstatechange', () => {
227
257
  this._debug(`connection state changed to ${pc.connectionState}`);
228
258
  switch (pc.connectionState) {
229
- case 'closed': this._resetConnection(); break;
259
+ case 'closed':
260
+ this._resetConnection(new Error('connectionState changed to closed'));
261
+ break;
230
262
  case 'connected':
231
263
  if (this._remoteStream == null) this._setRemoteStream(this._disconnectedRemoteStream);
232
264
  break;
@@ -240,7 +272,7 @@ class PeerState extends EventTargetPolyfill {
240
272
  // seems that on at least Chrome 90 the 'failed' state is terminal (it can never go back to
241
273
  // working) so a new RTCPeerConnection must be made.
242
274
  case 'failed':
243
- this._resetConnection();
275
+ this._resetConnection(new Error('connectionState changed to failed'));
244
276
  break;
245
277
  }
246
278
  });
@@ -325,26 +357,26 @@ class PeerState extends EventTargetPolyfill {
325
357
  return;
326
358
  }
327
359
  for (const idType of ['session', 'instance']) {
328
- const newId = ids.from[idType];
360
+ const newId = (ids.from || {})[idType];
329
361
  const currentId = (this._ids.to || {})[idType];
330
362
  if (currentId == null || newId === currentId) continue;
331
363
  if (newId == null || newId < currentId) return;
332
364
  // The remote peer reloaded the page or experienced an error. Destroy and recreate the local
333
365
  // RTCPeerConnection to avoid browser quirks caused by state mismatches.
334
- this._debug(`remote peer forced WebRTC renegotiation via new ${idType} ID ` +
335
- `(old ID ${currentId}, new ID ${newId})`);
336
- this._resetConnection(ids.from);
366
+ this._resetConnection(new Error(
367
+ `remote peer forced WebRTC renegotiation via new ${idType} ID ` +
368
+ `(old ID ${currentId}, new ID ${newId})`), ids.from);
337
369
  break;
338
370
  }
339
371
  this._ids.to = ids.from;
340
372
  }
341
- if (this._pc == null) this._resetConnection(this._ids.to);
373
+ if (this._pc == null) this._resetConnection(null, this._ids.to);
342
374
  try {
343
375
  if (description != null) await this._setRemoteDescription(description);
344
376
  if (candidate != null) await this._addIceCandidate(candidate);
345
377
  } catch (err) {
346
378
  console.error('Error processing message from peer:', err);
347
- this._resetConnection();
379
+ this._resetConnection(err);
348
380
  return;
349
381
  }
350
382
  }
@@ -409,16 +441,17 @@ exports.rtc = new class {
409
441
  const {kind} = oldTrack || newTrack;
410
442
  $videoContainer.find(`.${kind}ended-error-btn`)
411
443
  .css('display', newTrack == null ? '' : 'none');
412
- if (newTrack == null) {
413
- this.logErrorToServer(new Error(`Local ${kind} track ended unexpectedly`));
414
- }
444
+ if (newTrack == null) logErrorToServer(new Error(`Local ${kind} track ended unexpectedly`));
415
445
  ($videoContainer.data('updateMinSize') || (() => {}))();
416
446
 
417
447
  // Update the audio/video buttons to reflect the new state.
418
448
  if (newTrack != null) return;
419
449
  switch (oldTrack.kind) {
420
450
  case 'audio': this._selfViewButtons.audio.enabled = false; break;
421
- case 'video': this._selfViewButtons.video.enabled = false; break;
451
+ case 'video':
452
+ this._selfViewButtons.video.enabled = false;
453
+ this._selfViewButtons.screenshare.enabled = false;
454
+ break;
422
455
  }
423
456
  });
424
457
  this._pad = null;
@@ -541,30 +574,6 @@ exports.rtc = new class {
541
574
  ($videoContainer.data('updateMinSize') || (() => {}))();
542
575
  }
543
576
 
544
- async logErrorToServer(err, delay = 10000) {
545
- // Sleep to avoid logging benign errors caused by the user leaving the page (e.g., audio/video
546
- // stream ended unexpectedly). If the user navigates away during this sleep the error will not
547
- // be logged.
548
- if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
549
- // Mimick Etherpad core's global exception handler in pad_utils.js.
550
- const {message = 'unknown', fileName = 'unknown', lineNumber = -1} = err;
551
- let msg = message;
552
- if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {
553
- msg = `${err.name}: ${msg}`;
554
- }
555
- await $.post('../jserror', {
556
- errorInfo: JSON.stringify({
557
- type: 'Plugin ep_webrtc',
558
- msg,
559
- url: window.location.href,
560
- source: fileName,
561
- linenumber: lineNumber,
562
- userAgent: navigator.userAgent,
563
- stack: err.stack,
564
- }),
565
- });
566
- }
567
-
568
577
  showUserMediaError(err) { // show an error returned from getUserMedia
569
578
  err.devices.sort();
570
579
  const devices = err.devices.join('');
@@ -625,7 +634,7 @@ exports.rtc = new class {
625
634
  sticky: true,
626
635
  class_name: 'error',
627
636
  });
628
- this.logErrorToServer(err);
637
+ logErrorToServer(err);
629
638
  }
630
639
 
631
640
  // Performs the following steps for the local audio and/or video tracks:
@@ -646,27 +655,52 @@ exports.rtc = new class {
646
655
  (t) => t !== this._disabledSilence && t.readyState === 'live');
647
656
  if (addAudioTrack) devices.push('mic');
648
657
  const addVideoTrack = updateVideo && this._selfViewButtons.video.enabled &&
649
- !this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live');
658
+ (!this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live') ||
659
+ this._localTracks.videoIsScreenshare);
650
660
  if (addVideoTrack) devices.push('cam');
651
- if (addAudioTrack || addVideoTrack) {
661
+ const addScreenshareTrack = updateVideo && this._selfViewButtons.screenshare.enabled &&
662
+ !this._selfViewButtons.video.enabled && // Video button overrides screenshare button.
663
+ (!this._localTracks.stream.getVideoTracks().some((t) => t.readyState === 'live') ||
664
+ !this._localTracks.videoIsScreenshare);
665
+ const getUserMedia = async () => {
666
+ if (!addAudioTrack && !addVideoTrack) return new MediaStream();
652
667
  debug(`requesting permission to access ${devices.join(' and ')}`);
653
- let stream;
654
- try {
655
- stream = await window.navigator.mediaDevices.getUserMedia({
656
- audio: addAudioTrack,
657
- video: addVideoTrack && {width: {ideal: 320}, height: {ideal: 240}},
658
- });
659
- debug('successfully accessed device(s)');
660
- } catch (err) {
661
- // Display but otherwise ignore the error. The button(s) will be toggled back to
662
- // disabled below if we failed to access the microphone/camera. The user can re-click
663
- // the button to try again.
664
- err.devices = devices;
665
- (async () => this.showUserMediaError(err))();
666
- stream = new MediaStream();
667
- }
668
- for (const track of stream.getTracks()) this._localTracks.setTrack(track.kind, track);
669
- }
668
+ const stream = await window.navigator.mediaDevices.getUserMedia({
669
+ audio: addAudioTrack,
670
+ video: addVideoTrack && {width: {ideal: 320}, height: {ideal: 240}},
671
+ });
672
+ debug('successfully accessed device(s)');
673
+ return stream;
674
+ };
675
+ const getDisplayMedia = async () => {
676
+ if (!addScreenshareTrack) return new MediaStream();
677
+ debug('requesting permission to access screen');
678
+ const stream = await window.navigator.mediaDevices.getDisplayMedia({
679
+ video: {cursor: 'always'},
680
+ });
681
+ debug('successfully accessed screen');
682
+ return new MediaStream(stream.getVideoTracks());
683
+ };
684
+ await Promise.all([[getUserMedia, devices], [getDisplayMedia, ['screen']]].map(
685
+ async ([getMedia, devices]) => {
686
+ let stream;
687
+ try {
688
+ stream = await getMedia();
689
+ } catch (err) {
690
+ // Display but otherwise ignore the error. The button(s) will be toggled back to
691
+ // disabled below if we failed to access the microphone/camera. The user can re-click
692
+ // the button to try again.
693
+ err.devices = devices;
694
+ (async () => this.showUserMediaError(err))();
695
+ stream = new MediaStream();
696
+ }
697
+ for (const track of stream.getTracks()) {
698
+ if (track.kind === 'video') {
699
+ this._localTracks.videoIsScreenshare = devices.includes('screen');
700
+ }
701
+ this._localTracks.setTrack(track.kind, track);
702
+ }
703
+ }));
670
704
  if (updateAudio) {
671
705
  for (const track of this._localTracks.stream.getAudioTracks()) {
672
706
  // Re-check the state of the button because the user might have clicked it while
@@ -680,12 +714,15 @@ exports.rtc = new class {
680
714
  if (updateVideo) {
681
715
  for (const track of this._localTracks.stream.getVideoTracks()) {
682
716
  // Re-check the state of the button because the user might have clicked it while
683
- // getUserMedia() was running.
684
- track.enabled = this._selfViewButtons.video.enabled;
717
+ // getUserMedia() or getDisplayMedia() was running.
718
+ track.enabled = this._localTracks.videoIsScreenshare
719
+ ? this._selfViewButtons.screenshare.enabled : this._selfViewButtons.video.enabled;
685
720
  }
686
721
  const hasVideo = this._localTracks.stream.getVideoTracks().some(
687
722
  (t) => t.enabled && t.readyState === 'live');
688
- this._selfViewButtons.video.enabled = hasVideo;
723
+ this._selfViewButtons.video.enabled = hasVideo && !this._localTracks.videoIsScreenshare;
724
+ this._selfViewButtons.screenshare.enabled =
725
+ hasVideo && this._localTracks.videoIsScreenshare;
689
726
  }
690
727
  } finally {
691
728
  if (updateVideo) this._trackLocks.video.unlock();
@@ -739,8 +776,15 @@ exports.rtc = new class {
739
776
  const $rtcbox = $('#rtcbox');
740
777
  $rtcbox.empty(); // In case any peer videos didn't get cleaned up for some reason.
741
778
  $rtcbox.hide();
742
- for (const track of this._localTracks.stream.getTracks()) {
743
- this._localTracks.setTrack(track.kind, null);
779
+ await this._trackLocks.audio.lock();
780
+ await this._trackLocks.video.lock();
781
+ try {
782
+ for (const track of this._localTracks.stream.getTracks()) {
783
+ this._localTracks.setTrack(track.kind, null);
784
+ }
785
+ } finally {
786
+ this._trackLocks.video.unlock();
787
+ this._trackLocks.audio.unlock();
744
788
  }
745
789
  } finally {
746
790
  $checkbox.prop('disabled', false);
@@ -796,7 +840,7 @@ exports.rtc = new class {
796
840
  $video.data('automuted', true);
797
841
  return await this.playVideo($video);
798
842
  }
799
- this.logErrorToServer(err);
843
+ logErrorToServer(err);
800
844
  $playErrorBtn.css({display: ''});
801
845
  }
802
846
  ($videoContainer.data('updateMinSize') || (() => {}))();
@@ -931,10 +975,11 @@ exports.rtc = new class {
931
975
  });
932
976
 
933
977
  // /////
934
- // Disable Video button
978
+ // Video and Screen Sharing Buttons
935
979
  // /////
936
980
 
937
981
  let $videoBtn;
982
+ let $screenshareBtn;
938
983
  if (isLocal) {
939
984
  $videoBtn = $('<span>').addClass('interface-btn video-btn buttonicon').appendTo($interface);
940
985
  this._selfViewButtons.video = {
@@ -951,11 +996,43 @@ exports.rtc = new class {
951
996
  if (videoHardDisabled) {
952
997
  $videoBtn.attr('title', 'Video disallowed by admin').addClass('disallowed');
953
998
  }
999
+ const {navigator: {mediaDevices: {getDisplayMedia} = {}} = {}} = window;
1000
+ $screenshareBtn = $('<span>')
1001
+ .addClass('interface-btn screenshare-btn buttonicon')
1002
+ .css('display', typeof getDisplayMedia === 'function' ? '' : 'none')
1003
+ .appendTo($interface);
1004
+ this._selfViewButtons.screenshare = {
1005
+ get enabled() { return !$screenshareBtn.hasClass('off'); },
1006
+ set enabled(val) {
1007
+ $screenshareBtn
1008
+ .toggleClass('off', !val)
1009
+ .attr('title', val ? 'Stop screen share' : 'Start screen share');
1010
+ },
1011
+ };
1012
+ this._selfViewButtons.screenshare.enabled = false;
954
1013
  addAsyncEventHandlers($videoBtn, videoHardDisabled ? {} : {
955
1014
  click: async () => {
956
1015
  const videoEnabled = !this._selfViewButtons.video.enabled;
957
1016
  _debug(`video button clicked to ${videoEnabled ? 'en' : 'dis'}able video`);
958
1017
  this._selfViewButtons.video.enabled = videoEnabled;
1018
+ // Unconditionally disable screen sharing. Either the camera was previously disabled in
1019
+ // which case the user now wants to share camera video, or the camera was previously
1020
+ // enabled in which case the user now wants to shut off all video.
1021
+ this._selfViewButtons.screenshare.enabled = false;
1022
+ await this.updateLocalTracks({updateVideo: true});
1023
+ // Don't use `await` here -- see the comment for the audio button click handler above.
1024
+ this.unmuteAndPlayAll();
1025
+ },
1026
+ });
1027
+ addAsyncEventHandlers($screenshareBtn, {
1028
+ click: async () => {
1029
+ const screenshareEnabled = !this._selfViewButtons.screenshare.enabled;
1030
+ _debug(`button clicked to ${screenshareEnabled ? 'en' : 'dis'}able screen sharing`);
1031
+ // Unconditionally disable the camera. Either screen sharing was previously disabled in
1032
+ // which case the user now wants to share the screen, or screen sharing was previously
1033
+ // enabled in which case the user wants to shut off all video.
1034
+ this._selfViewButtons.video.enabled = false;
1035
+ this._selfViewButtons.screenshare.enabled = screenshareEnabled;
959
1036
  await this.updateLocalTracks({updateVideo: true});
960
1037
  // Don't use `await` here -- see the comment for the audio button click handler above.
961
1038
  this.unmuteAndPlayAll();
@@ -1042,7 +1119,7 @@ exports.rtc = new class {
1042
1119
  {
1043
1120
  cls: 'videoended-error-btn',
1044
1121
  title: 'Video stopped unexpectedly. Click to retry.',
1045
- click: () => $videoBtn.click(),
1122
+ click: () => (this._localTracks.videoIsScreenshare ? $screenshareBtn : $videoBtn).click(),
1046
1123
  },
1047
1124
  ] : []),
1048
1125
  ];
@@ -1224,7 +1301,7 @@ exports.rtc = new class {
1224
1301
  // The userLeave hook isn't called until it has been 8s since the peer left. Wait a bit
1225
1302
  // longer than that before logging the disconnect to the server.
1226
1303
  logDisconnectErrorTimeout =
1227
- setTimeout(() => this.logErrorToServer(new Error('RTC connection lost'), 0), 10000);
1304
+ setTimeout(() => logErrorToServer(new Error('RTC connection lost'), 0), 10000);
1228
1305
  ($videoContainer.data('updateMinSize') || (() => {}))();
1229
1306
  await this.setStream(userId, this._blankStream);
1230
1307
  });