@stream-io/video-client 1.14.0 → 1.15.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 (92) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +1533 -1783
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1514 -1783
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1533 -1783
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -28
  9. package/dist/src/StreamSfuClient.d.ts +4 -5
  10. package/dist/src/devices/CameraManager.d.ts +5 -8
  11. package/dist/src/devices/InputMediaDeviceManager.d.ts +5 -5
  12. package/dist/src/devices/MicrophoneManager.d.ts +7 -2
  13. package/dist/src/devices/ScreenShareManager.d.ts +1 -2
  14. package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
  15. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  16. package/dist/src/helpers/array.d.ts +7 -0
  17. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  18. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  19. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  20. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  21. package/dist/src/rtc/Publisher.d.ts +32 -86
  22. package/dist/src/rtc/Subscriber.d.ts +4 -56
  23. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  24. package/dist/src/rtc/codecs.d.ts +1 -15
  25. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  26. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  27. package/dist/src/rtc/index.d.ts +3 -0
  28. package/dist/src/rtc/videoLayers.d.ts +11 -25
  29. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  30. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  31. package/dist/src/stats/index.d.ts +1 -1
  32. package/dist/src/stats/types.d.ts +8 -0
  33. package/dist/src/types.d.ts +12 -22
  34. package/package.json +1 -1
  35. package/src/Call.ts +254 -268
  36. package/src/StreamSfuClient.ts +9 -14
  37. package/src/StreamVideoClient.ts +1 -1
  38. package/src/__tests__/Call.publishing.test.ts +306 -0
  39. package/src/devices/CameraManager.ts +33 -16
  40. package/src/devices/InputMediaDeviceManager.ts +38 -27
  41. package/src/devices/MicrophoneManager.ts +29 -8
  42. package/src/devices/ScreenShareManager.ts +6 -8
  43. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  44. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  45. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  46. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  47. package/src/devices/__tests__/mocks.ts +1 -0
  48. package/src/events/__tests__/internal.test.ts +132 -0
  49. package/src/events/__tests__/mutes.test.ts +0 -3
  50. package/src/events/__tests__/speaker.test.ts +92 -0
  51. package/src/events/participant.ts +3 -4
  52. package/src/gen/video/sfu/event/events.ts +91 -30
  53. package/src/gen/video/sfu/models/models.ts +105 -13
  54. package/src/helpers/array.ts +14 -0
  55. package/src/permissions/PermissionsContext.ts +22 -0
  56. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  57. package/src/rpc/__tests__/createClient.test.ts +38 -0
  58. package/src/rpc/createClient.ts +11 -5
  59. package/src/rtc/BasePeerConnection.ts +240 -0
  60. package/src/rtc/Dispatcher.ts +0 -9
  61. package/src/rtc/IceTrickleBuffer.ts +24 -4
  62. package/src/rtc/Publisher.ts +210 -528
  63. package/src/rtc/Subscriber.ts +26 -200
  64. package/src/rtc/TransceiverCache.ts +120 -0
  65. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  66. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  67. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  68. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  69. package/src/rtc/codecs.ts +1 -131
  70. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  71. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  72. package/src/rtc/helpers/sdp.ts +30 -0
  73. package/src/rtc/helpers/tracks.ts +3 -0
  74. package/src/rtc/index.ts +4 -0
  75. package/src/rtc/videoLayers.ts +68 -76
  76. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  77. package/src/stats/SfuStatsReporter.ts +31 -3
  78. package/src/stats/index.ts +1 -1
  79. package/src/stats/types.ts +12 -0
  80. package/src/types.ts +12 -22
  81. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  82. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  83. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  84. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  85. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  86. package/src/helpers/sdp-munging.ts +0 -265
  87. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  88. package/src/rtc/__tests__/codecs.test.ts +0 -145
  89. package/src/rtc/bitrateLookup.ts +0 -61
  90. package/src/rtc/helpers/iceCandidate.ts +0 -16
  91. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  92. /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
package/dist/index.cjs.js CHANGED
@@ -5,30 +5,11 @@ var runtime = require('@protobuf-ts/runtime');
5
5
  var runtimeRpc = require('@protobuf-ts/runtime-rpc');
6
6
  var axios = require('axios');
7
7
  var twirpTransport = require('@protobuf-ts/twirp-transport');
8
- var uaParserJs = require('ua-parser-js');
9
8
  var rxjs = require('rxjs');
10
- var SDP = require('sdp-transform');
9
+ var sdpTransform = require('sdp-transform');
10
+ var uaParserJs = require('ua-parser-js');
11
11
  var https = require('https');
12
12
 
13
- function _interopNamespaceDefault(e) {
14
- var n = Object.create(null);
15
- if (e) {
16
- Object.keys(e).forEach(function (k) {
17
- if (k !== 'default') {
18
- var d = Object.getOwnPropertyDescriptor(e, k);
19
- Object.defineProperty(n, k, d.get ? d : {
20
- enumerable: true,
21
- get: function () { return e[k]; }
22
- });
23
- }
24
- });
25
- }
26
- n.default = e;
27
- return Object.freeze(n);
28
- }
29
-
30
- var SDP__namespace = /*#__PURE__*/_interopNamespaceDefault(SDP);
31
-
32
13
  /* tslint:disable */
33
14
  /* eslint-disable */
34
15
  /**
@@ -1245,23 +1226,33 @@ class VideoLayer$Type extends runtime.MessageType {
1245
1226
  */
1246
1227
  const VideoLayer = new VideoLayer$Type();
1247
1228
  // @generated message type with reflection information, may provide speed optimized methods
1248
- class PublishOptions$Type extends runtime.MessageType {
1229
+ class SubscribeOption$Type extends runtime.MessageType {
1249
1230
  constructor() {
1250
- super('stream.video.sfu.models.PublishOptions', [
1231
+ super('stream.video.sfu.models.SubscribeOption', [
1251
1232
  {
1252
1233
  no: 1,
1234
+ name: 'track_type',
1235
+ kind: 'enum',
1236
+ T: () => [
1237
+ 'stream.video.sfu.models.TrackType',
1238
+ TrackType,
1239
+ 'TRACK_TYPE_',
1240
+ ],
1241
+ },
1242
+ {
1243
+ no: 2,
1253
1244
  name: 'codecs',
1254
1245
  kind: 'message',
1255
1246
  repeat: 1 /*RepeatType.PACKED*/,
1256
- T: () => PublishOption,
1247
+ T: () => Codec,
1257
1248
  },
1258
1249
  ]);
1259
1250
  }
1260
1251
  }
1261
1252
  /**
1262
- * @generated MessageType for protobuf message stream.video.sfu.models.PublishOptions
1253
+ * @generated MessageType for protobuf message stream.video.sfu.models.SubscribeOption
1263
1254
  */
1264
- const PublishOptions = new PublishOptions$Type();
1255
+ const SubscribeOption = new SubscribeOption$Type();
1265
1256
  // @generated message type with reflection information, may provide speed optimized methods
1266
1257
  class PublishOption$Type extends runtime.MessageType {
1267
1258
  constructor() {
@@ -1291,6 +1282,13 @@ class PublishOption$Type extends runtime.MessageType {
1291
1282
  kind: 'scalar',
1292
1283
  T: 5 /*ScalarType.INT32*/,
1293
1284
  },
1285
+ {
1286
+ no: 7,
1287
+ name: 'video_dimension',
1288
+ kind: 'message',
1289
+ T: () => VideoDimension,
1290
+ },
1291
+ { no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
1294
1292
  ]);
1295
1293
  }
1296
1294
  }
@@ -1303,7 +1301,7 @@ class Codec$Type extends runtime.MessageType {
1303
1301
  constructor() {
1304
1302
  super('stream.video.sfu.models.Codec', [
1305
1303
  {
1306
- no: 11,
1304
+ no: 16,
1307
1305
  name: 'payload_type',
1308
1306
  kind: 'scalar',
1309
1307
  T: 13 /*ScalarType.UINT32*/,
@@ -1316,7 +1314,7 @@ class Codec$Type extends runtime.MessageType {
1316
1314
  T: 13 /*ScalarType.UINT32*/,
1317
1315
  },
1318
1316
  {
1319
- no: 13,
1317
+ no: 15,
1320
1318
  name: 'encoding_parameters',
1321
1319
  kind: 'scalar',
1322
1320
  T: 9 /*ScalarType.STRING*/,
@@ -1380,6 +1378,13 @@ class TrackInfo$Type extends runtime.MessageType {
1380
1378
  { no: 8, name: 'stereo', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1381
1379
  { no: 9, name: 'red', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1382
1380
  { no: 10, name: 'muted', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1381
+ { no: 11, name: 'codec', kind: 'message', T: () => Codec },
1382
+ {
1383
+ no: 12,
1384
+ name: 'publish_option_id',
1385
+ kind: 'scalar',
1386
+ T: 5 /*ScalarType.INT32*/,
1387
+ },
1383
1388
  ]);
1384
1389
  }
1385
1390
  }
@@ -1653,10 +1658,10 @@ var models = /*#__PURE__*/Object.freeze({
1653
1658
  get PeerType () { return PeerType; },
1654
1659
  Pin: Pin,
1655
1660
  PublishOption: PublishOption,
1656
- PublishOptions: PublishOptions,
1657
1661
  Sdk: Sdk,
1658
1662
  get SdkType () { return SdkType; },
1659
1663
  StreamQuality: StreamQuality,
1664
+ SubscribeOption: SubscribeOption,
1660
1665
  TrackInfo: TrackInfo,
1661
1666
  get TrackType () { return TrackType; },
1662
1667
  get TrackUnpublishReason () { return TrackUnpublishReason; },
@@ -2286,13 +2291,6 @@ class SfuEvent$Type extends runtime.MessageType {
2286
2291
  oneof: 'eventPayload',
2287
2292
  T: () => ParticipantMigrationComplete,
2288
2293
  },
2289
- {
2290
- no: 26,
2291
- name: 'codec_negotiation_complete',
2292
- kind: 'message',
2293
- oneof: 'eventPayload',
2294
- T: () => CodecNegotiationComplete,
2295
- },
2296
2294
  {
2297
2295
  no: 27,
2298
2296
  name: 'change_publish_options',
@@ -2313,10 +2311,12 @@ class ChangePublishOptions$Type extends runtime.MessageType {
2313
2311
  super('stream.video.sfu.event.ChangePublishOptions', [
2314
2312
  {
2315
2313
  no: 1,
2316
- name: 'publish_option',
2314
+ name: 'publish_options',
2317
2315
  kind: 'message',
2316
+ repeat: 1 /*RepeatType.PACKED*/,
2318
2317
  T: () => PublishOption,
2319
2318
  },
2319
+ { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2320
2320
  ]);
2321
2321
  }
2322
2322
  }
@@ -2325,15 +2325,15 @@ class ChangePublishOptions$Type extends runtime.MessageType {
2325
2325
  */
2326
2326
  const ChangePublishOptions = new ChangePublishOptions$Type();
2327
2327
  // @generated message type with reflection information, may provide speed optimized methods
2328
- class CodecNegotiationComplete$Type extends runtime.MessageType {
2328
+ class ChangePublishOptionsComplete$Type extends runtime.MessageType {
2329
2329
  constructor() {
2330
- super('stream.video.sfu.event.CodecNegotiationComplete', []);
2330
+ super('stream.video.sfu.event.ChangePublishOptionsComplete', []);
2331
2331
  }
2332
2332
  }
2333
2333
  /**
2334
- * @generated MessageType for protobuf message stream.video.sfu.event.CodecNegotiationComplete
2334
+ * @generated MessageType for protobuf message stream.video.sfu.event.ChangePublishOptionsComplete
2335
2335
  */
2336
- const CodecNegotiationComplete = new CodecNegotiationComplete$Type();
2336
+ const ChangePublishOptionsComplete = new ChangePublishOptionsComplete$Type();
2337
2337
  // @generated message type with reflection information, may provide speed optimized methods
2338
2338
  class ParticipantMigrationComplete$Type extends runtime.MessageType {
2339
2339
  constructor() {
@@ -2591,6 +2591,20 @@ class JoinRequest$Type extends runtime.MessageType {
2591
2591
  kind: 'message',
2592
2592
  T: () => ReconnectDetails,
2593
2593
  },
2594
+ {
2595
+ no: 9,
2596
+ name: 'preferred_publish_options',
2597
+ kind: 'message',
2598
+ repeat: 1 /*RepeatType.PACKED*/,
2599
+ T: () => PublishOption,
2600
+ },
2601
+ {
2602
+ no: 10,
2603
+ name: 'preferred_subscribe_options',
2604
+ kind: 'message',
2605
+ repeat: 1 /*RepeatType.PACKED*/,
2606
+ T: () => SubscribeOption,
2607
+ },
2594
2608
  ]);
2595
2609
  }
2596
2610
  }
@@ -2698,7 +2712,8 @@ class JoinResponse$Type extends runtime.MessageType {
2698
2712
  no: 4,
2699
2713
  name: 'publish_options',
2700
2714
  kind: 'message',
2701
- T: () => PublishOptions,
2715
+ repeat: 1 /*RepeatType.PACKED*/,
2716
+ T: () => PublishOption,
2702
2717
  },
2703
2718
  ]);
2704
2719
  }
@@ -2863,6 +2878,22 @@ class AudioSender$Type extends runtime.MessageType {
2863
2878
  constructor() {
2864
2879
  super('stream.video.sfu.event.AudioSender', [
2865
2880
  { no: 2, name: 'codec', kind: 'message', T: () => Codec },
2881
+ {
2882
+ no: 3,
2883
+ name: 'track_type',
2884
+ kind: 'enum',
2885
+ T: () => [
2886
+ 'stream.video.sfu.models.TrackType',
2887
+ TrackType,
2888
+ 'TRACK_TYPE_',
2889
+ ],
2890
+ },
2891
+ {
2892
+ no: 4,
2893
+ name: 'publish_option_id',
2894
+ kind: 'scalar',
2895
+ T: 5 /*ScalarType.INT32*/,
2896
+ },
2866
2897
  ]);
2867
2898
  }
2868
2899
  }
@@ -2915,6 +2946,22 @@ class VideoSender$Type extends runtime.MessageType {
2915
2946
  repeat: 1 /*RepeatType.PACKED*/,
2916
2947
  T: () => VideoLayerSetting,
2917
2948
  },
2949
+ {
2950
+ no: 4,
2951
+ name: 'track_type',
2952
+ kind: 'enum',
2953
+ T: () => [
2954
+ 'stream.video.sfu.models.TrackType',
2955
+ TrackType,
2956
+ 'TRACK_TYPE_',
2957
+ ],
2958
+ },
2959
+ {
2960
+ no: 5,
2961
+ name: 'publish_option_id',
2962
+ kind: 'scalar',
2963
+ T: 5 /*ScalarType.INT32*/,
2964
+ },
2918
2965
  ]);
2919
2966
  }
2920
2967
  }
@@ -3011,8 +3058,8 @@ var events = /*#__PURE__*/Object.freeze({
3011
3058
  CallEnded: CallEnded,
3012
3059
  CallGrantsUpdated: CallGrantsUpdated,
3013
3060
  ChangePublishOptions: ChangePublishOptions,
3061
+ ChangePublishOptionsComplete: ChangePublishOptionsComplete,
3014
3062
  ChangePublishQuality: ChangePublishQuality,
3015
- CodecNegotiationComplete: CodecNegotiationComplete,
3016
3063
  ConnectionQualityChanged: ConnectionQualityChanged,
3017
3064
  ConnectionQualityInfo: ConnectionQualityInfo,
3018
3065
  DominantSpeakerChanged: DominantSpeakerChanged,
@@ -3159,11 +3206,18 @@ const withHeaders = (headers) => {
3159
3206
  const withRequestLogger = (logger, level) => {
3160
3207
  return {
3161
3208
  interceptUnary: (next, method, input, options) => {
3162
- logger(level, `Calling SFU RPC method ${method.name}`, {
3163
- input,
3164
- options,
3165
- });
3166
- return next(method, input, options);
3209
+ let invocation;
3210
+ try {
3211
+ invocation = next(method, input, options);
3212
+ }
3213
+ finally {
3214
+ logger(level, `Invoked SFU RPC method ${method.name}`, {
3215
+ request: invocation?.request,
3216
+ headers: invocation?.requestHeaders,
3217
+ response: invocation?.response,
3218
+ });
3219
+ }
3220
+ return invocation;
3167
3221
  },
3168
3222
  };
3169
3223
  };
@@ -3380,374 +3434,98 @@ const retryable = async (rpc, signal) => {
3380
3434
  return result;
3381
3435
  };
3382
3436
 
3383
- const version = "1.14.0";
3384
- const [major, minor, patch] = version.split('.');
3385
- let sdkInfo = {
3386
- type: SdkType.PLAIN_JAVASCRIPT,
3387
- major,
3388
- minor,
3389
- patch,
3390
- };
3391
- let osInfo;
3392
- let deviceInfo;
3393
- let webRtcInfo;
3394
- let deviceState = { oneofKind: undefined };
3395
- const setSdkInfo = (info) => {
3396
- sdkInfo = info;
3397
- };
3398
- const getSdkInfo = () => {
3399
- return sdkInfo;
3400
- };
3401
- const setOSInfo = (info) => {
3402
- osInfo = info;
3403
- };
3404
- const getOSInfo = () => {
3405
- return osInfo;
3406
- };
3407
- const setDeviceInfo = (info) => {
3408
- deviceInfo = info;
3437
+ /**
3438
+ * Returns a generic SDP for the given direction.
3439
+ * We use this SDP to send it as part of our JoinRequest so that the SFU
3440
+ * can use it to determine the client's codec capabilities.
3441
+ *
3442
+ * @param direction the direction of the transceiver.
3443
+ */
3444
+ const getGenericSdp = async (direction) => {
3445
+ const tempPc = new RTCPeerConnection();
3446
+ tempPc.addTransceiver('video', { direction });
3447
+ tempPc.addTransceiver('audio', { direction });
3448
+ const offer = await tempPc.createOffer();
3449
+ const sdp = offer.sdp ?? '';
3450
+ tempPc.getTransceivers().forEach((t) => {
3451
+ t.stop?.();
3452
+ });
3453
+ tempPc.close();
3454
+ return sdp;
3409
3455
  };
3410
- const getDeviceInfo = () => {
3411
- return deviceInfo;
3456
+ /**
3457
+ * Returns whether the codec is an SVC codec.
3458
+ *
3459
+ * @param codecOrMimeType the codec to check.
3460
+ */
3461
+ const isSvcCodec = (codecOrMimeType) => {
3462
+ if (!codecOrMimeType)
3463
+ return false;
3464
+ codecOrMimeType = codecOrMimeType.toLowerCase();
3465
+ return (codecOrMimeType === 'vp9' ||
3466
+ codecOrMimeType === 'av1' ||
3467
+ codecOrMimeType === 'video/vp9' ||
3468
+ codecOrMimeType === 'video/av1');
3412
3469
  };
3413
- const getWebRTCInfo = () => {
3414
- return webRtcInfo;
3470
+
3471
+ const sfuEventKinds = {
3472
+ subscriberOffer: undefined,
3473
+ publisherAnswer: undefined,
3474
+ connectionQualityChanged: undefined,
3475
+ audioLevelChanged: undefined,
3476
+ iceTrickle: undefined,
3477
+ changePublishQuality: undefined,
3478
+ participantJoined: undefined,
3479
+ participantLeft: undefined,
3480
+ dominantSpeakerChanged: undefined,
3481
+ joinResponse: undefined,
3482
+ healthCheckResponse: undefined,
3483
+ trackPublished: undefined,
3484
+ trackUnpublished: undefined,
3485
+ error: undefined,
3486
+ callGrantsUpdated: undefined,
3487
+ goAway: undefined,
3488
+ iceRestart: undefined,
3489
+ pinsUpdated: undefined,
3490
+ callEnded: undefined,
3491
+ participantUpdated: undefined,
3492
+ participantMigrationComplete: undefined,
3493
+ changePublishOptions: undefined,
3415
3494
  };
3416
- const setWebRTCInfo = (info) => {
3417
- webRtcInfo = info;
3495
+ const isSfuEvent = (eventName) => {
3496
+ return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3418
3497
  };
3419
- const setThermalState = (state) => {
3420
- if (!osInfo) {
3421
- deviceState = { oneofKind: undefined };
3422
- return;
3423
- }
3424
- if (osInfo.name === 'android') {
3425
- const thermalState = AndroidThermalState[state] ||
3426
- AndroidThermalState.UNSPECIFIED;
3427
- deviceState = {
3428
- oneofKind: 'android',
3429
- android: {
3430
- thermalState,
3431
- isPowerSaverMode: deviceState?.oneofKind === 'android' &&
3432
- deviceState.android.isPowerSaverMode,
3433
- },
3498
+ class Dispatcher {
3499
+ constructor() {
3500
+ this.logger = getLogger(['Dispatcher']);
3501
+ this.subscribers = {};
3502
+ this.dispatch = (message, logTag = '0') => {
3503
+ const eventKind = message.eventPayload.oneofKind;
3504
+ if (!eventKind)
3505
+ return;
3506
+ const payload = message.eventPayload[eventKind];
3507
+ this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3508
+ const listeners = this.subscribers[eventKind];
3509
+ if (!listeners)
3510
+ return;
3511
+ for (const fn of listeners) {
3512
+ try {
3513
+ fn(payload);
3514
+ }
3515
+ catch (e) {
3516
+ this.logger('warn', 'Listener failed with error', e);
3517
+ }
3518
+ }
3434
3519
  };
3435
- }
3436
- if (osInfo.name.toLowerCase() === 'ios') {
3437
- const thermalState = AppleThermalState[state] ||
3438
- AppleThermalState.UNSPECIFIED;
3439
- deviceState = {
3440
- oneofKind: 'apple',
3441
- apple: {
3442
- thermalState,
3443
- isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
3444
- deviceState.apple.isLowPowerModeEnabled,
3445
- },
3520
+ this.on = (eventName, fn) => {
3521
+ var _a;
3522
+ ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3523
+ return () => {
3524
+ this.off(eventName, fn);
3525
+ };
3446
3526
  };
3447
- }
3448
- };
3449
- const setPowerState = (powerMode) => {
3450
- if (!osInfo) {
3451
- deviceState = { oneofKind: undefined };
3452
- return;
3453
- }
3454
- if (osInfo.name === 'android') {
3455
- deviceState = {
3456
- oneofKind: 'android',
3457
- android: {
3458
- thermalState: deviceState?.oneofKind === 'android'
3459
- ? deviceState.android.thermalState
3460
- : AndroidThermalState.UNSPECIFIED,
3461
- isPowerSaverMode: powerMode,
3462
- },
3463
- };
3464
- }
3465
- if (osInfo.name.toLowerCase() === 'ios') {
3466
- deviceState = {
3467
- oneofKind: 'apple',
3468
- apple: {
3469
- thermalState: deviceState?.oneofKind === 'apple'
3470
- ? deviceState.apple.thermalState
3471
- : AppleThermalState.UNSPECIFIED,
3472
- isLowPowerModeEnabled: powerMode,
3473
- },
3474
- };
3475
- }
3476
- };
3477
- const getDeviceState = () => {
3478
- return deviceState;
3479
- };
3480
- const getClientDetails = () => {
3481
- if (isReactNative()) {
3482
- // Since RN doesn't support web, sharing browser info is not required
3483
- return {
3484
- sdk: getSdkInfo(),
3485
- os: getOSInfo(),
3486
- device: getDeviceInfo(),
3487
- };
3488
- }
3489
- const userAgent = new uaParserJs.UAParser(navigator.userAgent);
3490
- const { browser, os, device, cpu } = userAgent.getResult();
3491
- return {
3492
- sdk: getSdkInfo(),
3493
- browser: {
3494
- name: browser.name || navigator.userAgent,
3495
- version: browser.version || '',
3496
- },
3497
- os: {
3498
- name: os.name || '',
3499
- version: os.version || '',
3500
- architecture: cpu.architecture || '',
3501
- },
3502
- device: {
3503
- name: [device.vendor, device.model, device.type]
3504
- .filter(Boolean)
3505
- .join(' '),
3506
- version: '',
3507
- },
3508
- };
3509
- };
3510
-
3511
- /**
3512
- * Checks whether the current browser is Safari.
3513
- */
3514
- const isSafari = () => {
3515
- if (typeof navigator === 'undefined')
3516
- return false;
3517
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
3518
- };
3519
- /**
3520
- * Checks whether the current browser is Firefox.
3521
- */
3522
- const isFirefox = () => {
3523
- if (typeof navigator === 'undefined')
3524
- return false;
3525
- return navigator.userAgent?.includes('Firefox');
3526
- };
3527
- /**
3528
- * Checks whether the current browser is Google Chrome.
3529
- */
3530
- const isChrome = () => {
3531
- if (typeof navigator === 'undefined')
3532
- return false;
3533
- return navigator.userAgent?.includes('Chrome');
3534
- };
3535
-
3536
- var browsers = /*#__PURE__*/Object.freeze({
3537
- __proto__: null,
3538
- isChrome: isChrome,
3539
- isFirefox: isFirefox,
3540
- isSafari: isSafari
3541
- });
3542
-
3543
- /**
3544
- * Returns back a list of sorted codecs, with the preferred codec first.
3545
- *
3546
- * @param kind the kind of codec to get.
3547
- * @param preferredCodec the codec to prioritize (vp8, h264, vp9, av1...).
3548
- * @param codecToRemove the codec to exclude from the list.
3549
- * @param codecPreferencesSource the source of the codec preferences.
3550
- */
3551
- const getPreferredCodecs = (kind, preferredCodec, codecToRemove, codecPreferencesSource) => {
3552
- const source = codecPreferencesSource === 'receiver' ? RTCRtpReceiver : RTCRtpSender;
3553
- if (!('getCapabilities' in source))
3554
- return;
3555
- const capabilities = source.getCapabilities(kind);
3556
- if (!capabilities)
3557
- return;
3558
- const preferred = [];
3559
- const partiallyPreferred = [];
3560
- const unpreferred = [];
3561
- const preferredCodecMimeType = `${kind}/${preferredCodec.toLowerCase()}`;
3562
- const codecToRemoveMimeType = codecToRemove && `${kind}/${codecToRemove.toLowerCase()}`;
3563
- for (const codec of capabilities.codecs) {
3564
- const codecMimeType = codec.mimeType.toLowerCase();
3565
- const shouldRemoveCodec = codecMimeType === codecToRemoveMimeType;
3566
- if (shouldRemoveCodec)
3567
- continue; // skip this codec
3568
- const isPreferredCodec = codecMimeType === preferredCodecMimeType;
3569
- if (!isPreferredCodec) {
3570
- unpreferred.push(codec);
3571
- continue;
3572
- }
3573
- // h264 is a special case, we want to prioritize the baseline codec with
3574
- // profile-level-id is 42e01f and packetization-mode=0 for maximum
3575
- // cross-browser compatibility.
3576
- // this branch covers the other cases, such as vp8.
3577
- if (codecMimeType !== 'video/h264') {
3578
- preferred.push(codec);
3579
- continue;
3580
- }
3581
- const sdpFmtpLine = codec.sdpFmtpLine;
3582
- if (!sdpFmtpLine || !sdpFmtpLine.includes('profile-level-id=42')) {
3583
- // this is not the baseline h264 codec, prioritize it lower
3584
- partiallyPreferred.push(codec);
3585
- continue;
3586
- }
3587
- if (sdpFmtpLine.includes('packetization-mode=1')) {
3588
- preferred.unshift(codec);
3589
- }
3590
- else {
3591
- preferred.push(codec);
3592
- }
3593
- }
3594
- // return a sorted list of codecs, with the preferred codecs first
3595
- return [...preferred, ...partiallyPreferred, ...unpreferred];
3596
- };
3597
- /**
3598
- * Returns a generic SDP for the given direction.
3599
- * We use this SDP to send it as part of our JoinRequest so that the SFU
3600
- * can use it to determine client's codec capabilities.
3601
- *
3602
- * @param direction the direction of the transceiver.
3603
- */
3604
- const getGenericSdp = async (direction) => {
3605
- const tempPc = new RTCPeerConnection();
3606
- tempPc.addTransceiver('video', { direction });
3607
- tempPc.addTransceiver('audio', { direction });
3608
- const offer = await tempPc.createOffer();
3609
- const sdp = offer.sdp ?? '';
3610
- tempPc.getTransceivers().forEach((t) => {
3611
- t.stop?.();
3612
- });
3613
- tempPc.close();
3614
- return sdp;
3615
- };
3616
- /**
3617
- * Returns the optimal video codec for the device.
3618
- */
3619
- const getOptimalVideoCodec = (preferredCodec) => {
3620
- if (isReactNative()) {
3621
- const os = getOSInfo()?.name.toLowerCase();
3622
- if (os === 'android')
3623
- return preferredOr(preferredCodec, 'vp8');
3624
- if (os === 'ios' || os === 'ipados') {
3625
- return supportsH264Baseline() ? 'h264' : 'vp8';
3626
- }
3627
- return preferredOr(preferredCodec, 'h264');
3628
- }
3629
- if (isSafari())
3630
- return 'h264';
3631
- if (isFirefox())
3632
- return 'vp8';
3633
- return preferredOr(preferredCodec, 'vp8');
3634
- };
3635
- /**
3636
- * Determines if the platform supports the preferred codec.
3637
- * If not, it returns the fallback codec.
3638
- */
3639
- const preferredOr = (codec, fallback) => {
3640
- if (!codec)
3641
- return fallback;
3642
- if (!('getCapabilities' in RTCRtpSender))
3643
- return fallback;
3644
- const capabilities = RTCRtpSender.getCapabilities('video');
3645
- if (!capabilities)
3646
- return fallback;
3647
- // Safari and Firefox do not have a good support encoding to SVC codecs,
3648
- // so we disable it for them.
3649
- if (isSvcCodec(codec) && (isSafari() || isFirefox()))
3650
- return fallback;
3651
- const { codecs } = capabilities;
3652
- const codecMimeType = `video/${codec}`.toLowerCase();
3653
- return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
3654
- ? codec
3655
- : fallback;
3656
- };
3657
- /**
3658
- * Returns whether the platform supports the H264 baseline codec.
3659
- */
3660
- const supportsH264Baseline = () => {
3661
- if (!('getCapabilities' in RTCRtpSender))
3662
- return false;
3663
- const capabilities = RTCRtpSender.getCapabilities('video');
3664
- if (!capabilities)
3665
- return false;
3666
- return capabilities.codecs.some((c) => c.mimeType.toLowerCase() === 'video/h264' &&
3667
- c.sdpFmtpLine?.includes('profile-level-id=42e01f'));
3668
- };
3669
- /**
3670
- * Returns whether the codec is an SVC codec.
3671
- *
3672
- * @param codecOrMimeType the codec to check.
3673
- */
3674
- const isSvcCodec = (codecOrMimeType) => {
3675
- if (!codecOrMimeType)
3676
- return false;
3677
- codecOrMimeType = codecOrMimeType.toLowerCase();
3678
- return (codecOrMimeType === 'vp9' ||
3679
- codecOrMimeType === 'av1' ||
3680
- codecOrMimeType === 'video/vp9' ||
3681
- codecOrMimeType === 'video/av1');
3682
- };
3683
-
3684
- const sfuEventKinds = {
3685
- subscriberOffer: undefined,
3686
- publisherAnswer: undefined,
3687
- connectionQualityChanged: undefined,
3688
- audioLevelChanged: undefined,
3689
- iceTrickle: undefined,
3690
- changePublishQuality: undefined,
3691
- participantJoined: undefined,
3692
- participantLeft: undefined,
3693
- dominantSpeakerChanged: undefined,
3694
- joinResponse: undefined,
3695
- healthCheckResponse: undefined,
3696
- trackPublished: undefined,
3697
- trackUnpublished: undefined,
3698
- error: undefined,
3699
- callGrantsUpdated: undefined,
3700
- goAway: undefined,
3701
- iceRestart: undefined,
3702
- pinsUpdated: undefined,
3703
- callEnded: undefined,
3704
- participantUpdated: undefined,
3705
- participantMigrationComplete: undefined,
3706
- codecNegotiationComplete: undefined,
3707
- changePublishOptions: undefined,
3708
- };
3709
- const isSfuEvent = (eventName) => {
3710
- return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3711
- };
3712
- class Dispatcher {
3713
- constructor() {
3714
- this.logger = getLogger(['Dispatcher']);
3715
- this.subscribers = {};
3716
- this.dispatch = (message, logTag = '0') => {
3717
- const eventKind = message.eventPayload.oneofKind;
3718
- if (!eventKind)
3719
- return;
3720
- const payload = message.eventPayload[eventKind];
3721
- this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3722
- const listeners = this.subscribers[eventKind];
3723
- if (!listeners)
3724
- return;
3725
- for (const fn of listeners) {
3726
- try {
3727
- fn(payload);
3728
- }
3729
- catch (e) {
3730
- this.logger('warn', 'Listener failed with error', e);
3731
- }
3732
- }
3733
- };
3734
- this.on = (eventName, fn) => {
3735
- var _a;
3736
- ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3737
- return () => {
3738
- this.off(eventName, fn);
3739
- };
3740
- };
3741
- this.off = (eventName, fn) => {
3742
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3743
- };
3744
- this.offAll = (eventName) => {
3745
- if (eventName) {
3746
- this.subscribers[eventName] = [];
3747
- }
3748
- else {
3749
- this.subscribers = {};
3750
- }
3527
+ this.off = (eventName, fn) => {
3528
+ this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3751
3529
  };
3752
3530
  }
3753
3531
  }
@@ -3761,285 +3539,35 @@ class IceTrickleBuffer {
3761
3539
  this.subscriberCandidates = new rxjs.ReplaySubject();
3762
3540
  this.publisherCandidates = new rxjs.ReplaySubject();
3763
3541
  this.push = (iceTrickle) => {
3542
+ const iceCandidate = toIceCandidate(iceTrickle);
3543
+ if (!iceCandidate)
3544
+ return;
3764
3545
  if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
3765
- this.subscriberCandidates.next(iceTrickle);
3546
+ this.subscriberCandidates.next(iceCandidate);
3766
3547
  }
3767
3548
  else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
3768
- this.publisherCandidates.next(iceTrickle);
3549
+ this.publisherCandidates.next(iceCandidate);
3769
3550
  }
3770
3551
  else {
3771
3552
  const logger = getLogger(['sfu-client']);
3772
3553
  logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
3773
3554
  }
3774
3555
  };
3556
+ this.dispose = () => {
3557
+ this.subscriberCandidates.complete();
3558
+ this.publisherCandidates.complete();
3559
+ };
3560
+ }
3561
+ }
3562
+ const toIceCandidate = (iceTrickle) => {
3563
+ try {
3564
+ return JSON.parse(iceTrickle.iceCandidate);
3565
+ }
3566
+ catch (e) {
3567
+ const logger = getLogger(['sfu-client']);
3568
+ logger('error', `Failed to parse ICE Trickle`, e, iceTrickle);
3569
+ return undefined;
3775
3570
  }
3776
- }
3777
-
3778
- function getIceCandidate(candidate) {
3779
- if (!candidate.usernameFragment) {
3780
- // react-native-webrtc doesn't include usernameFragment in the candidate
3781
- const splittedCandidate = candidate.candidate.split(' ');
3782
- const ufragIndex = splittedCandidate.findIndex((s) => s === 'ufrag') + 1;
3783
- const usernameFragment = splittedCandidate[ufragIndex];
3784
- return JSON.stringify({ ...candidate, usernameFragment });
3785
- }
3786
- else {
3787
- return JSON.stringify(candidate.toJSON());
3788
- }
3789
- }
3790
-
3791
- const bitrateLookupTable = {
3792
- h264: {
3793
- 2160: 5000000,
3794
- 1440: 3000000,
3795
- 1080: 2000000,
3796
- 720: 1250000,
3797
- 540: 750000,
3798
- 360: 400000,
3799
- default: 1250000,
3800
- },
3801
- vp8: {
3802
- 2160: 5000000,
3803
- 1440: 2750000,
3804
- 1080: 2000000,
3805
- 720: 1250000,
3806
- 540: 600000,
3807
- 360: 350000,
3808
- default: 1250000,
3809
- },
3810
- vp9: {
3811
- 2160: 3000000,
3812
- 1440: 2000000,
3813
- 1080: 1500000,
3814
- 720: 1250000,
3815
- 540: 500000,
3816
- 360: 275000,
3817
- default: 1250000,
3818
- },
3819
- av1: {
3820
- 2160: 2000000,
3821
- 1440: 1550000,
3822
- 1080: 1000000,
3823
- 720: 600000,
3824
- 540: 350000,
3825
- 360: 200000,
3826
- default: 600000,
3827
- },
3828
- };
3829
- const getOptimalBitrate = (codec, frameHeight) => {
3830
- const codecLookup = bitrateLookupTable[codec];
3831
- if (!codecLookup)
3832
- throw new Error(`Unknown codec: ${codec}`);
3833
- let bitrate = codecLookup[frameHeight];
3834
- if (!bitrate) {
3835
- const keys = Object.keys(codecLookup).map(Number);
3836
- const nearest = keys.reduce((a, b) => Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a);
3837
- bitrate = codecLookup[nearest];
3838
- }
3839
- return bitrate ?? codecLookup.default;
3840
- };
3841
-
3842
- const DEFAULT_BITRATE = 1250000;
3843
- const defaultTargetResolution = {
3844
- bitrate: DEFAULT_BITRATE,
3845
- width: 1280,
3846
- height: 720,
3847
- };
3848
- const defaultBitratePerRid = {
3849
- q: 300000,
3850
- h: 750000,
3851
- f: DEFAULT_BITRATE,
3852
- };
3853
- /**
3854
- * In SVC, we need to send only one video encoding (layer).
3855
- * this layer will have the additional spatial and temporal layers
3856
- * defined via the scalabilityMode property.
3857
- *
3858
- * @param layers the layers to process.
3859
- */
3860
- const toSvcEncodings = (layers) => {
3861
- // we take the `f` layer, and we rename it to `q`.
3862
- return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
3863
- };
3864
- /**
3865
- * Converts the rid to a video quality.
3866
- */
3867
- const ridToVideoQuality = (rid) => {
3868
- return rid === 'q'
3869
- ? VideoQuality.LOW_UNSPECIFIED
3870
- : rid === 'h'
3871
- ? VideoQuality.MID
3872
- : VideoQuality.HIGH; // default to HIGH
3873
- };
3874
- /**
3875
- * Determines the most optimal video layers for simulcasting
3876
- * for the given track.
3877
- *
3878
- * @param videoTrack the video track to find optimal layers for.
3879
- * @param targetResolution the expected target resolution.
3880
- * @param codecInUse the codec in use.
3881
- * @param publishOptions the publish options for the track.
3882
- */
3883
- const findOptimalVideoLayers = (videoTrack, targetResolution = defaultTargetResolution, codecInUse, publishOptions) => {
3884
- const optimalVideoLayers = [];
3885
- const settings = videoTrack.getSettings();
3886
- const { width = 0, height = 0 } = settings;
3887
- const { scalabilityMode, bitrateDownscaleFactor = 2, maxSimulcastLayers = 3, } = publishOptions || {};
3888
- const maxBitrate = getComputedMaxBitrate(targetResolution, width, height, codecInUse, publishOptions);
3889
- let downscaleFactor = 1;
3890
- let bitrateFactor = 1;
3891
- const svcCodec = isSvcCodec(codecInUse);
3892
- const totalLayers = svcCodec ? 3 : Math.min(3, maxSimulcastLayers);
3893
- for (const rid of ['f', 'h', 'q'].slice(0, totalLayers)) {
3894
- const layer = {
3895
- active: true,
3896
- rid,
3897
- width: Math.round(width / downscaleFactor),
3898
- height: Math.round(height / downscaleFactor),
3899
- maxBitrate: Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
3900
- maxFramerate: 30,
3901
- };
3902
- if (svcCodec) {
3903
- // for SVC codecs, we need to set the scalability mode, and the
3904
- // codec will handle the rest (layers, temporal layers, etc.)
3905
- layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
3906
- }
3907
- else {
3908
- // for non-SVC codecs, we need to downscale proportionally (simulcast)
3909
- layer.scaleResolutionDownBy = downscaleFactor;
3910
- }
3911
- downscaleFactor *= 2;
3912
- bitrateFactor *= bitrateDownscaleFactor;
3913
- // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
3914
- // when deciding which layer to disable when CPU or bandwidth is constrained.
3915
- // Encodings should be ordered in increasing spatial resolution order.
3916
- optimalVideoLayers.unshift(layer);
3917
- }
3918
- // for simplicity, we start with all layers enabled, then this function
3919
- // will clear/reassign the layers that are not needed
3920
- return withSimulcastConstraints(settings, optimalVideoLayers);
3921
- };
3922
- /**
3923
- * Computes the maximum bitrate for a given resolution.
3924
- * If the current resolution is lower than the target resolution,
3925
- * we want to proportionally reduce the target bitrate.
3926
- * If the current resolution is higher than the target resolution,
3927
- * we want to use the target bitrate.
3928
- *
3929
- * @param targetResolution the target resolution.
3930
- * @param currentWidth the current width of the track.
3931
- * @param currentHeight the current height of the track.
3932
- * @param codecInUse the codec in use.
3933
- * @param publishOptions the publish options.
3934
- */
3935
- const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, codecInUse, publishOptions) => {
3936
- // if the current resolution is lower than the target resolution,
3937
- // we want to proportionally reduce the target bitrate
3938
- const { width: targetWidth, height: targetHeight, bitrate: targetBitrate, } = targetResolution;
3939
- const { preferredBitrate } = publishOptions || {};
3940
- const frameHeight = currentWidth > currentHeight ? currentHeight : currentWidth;
3941
- const bitrate = preferredBitrate ||
3942
- (codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
3943
- if (currentWidth < targetWidth || currentHeight < targetHeight) {
3944
- const currentPixels = currentWidth * currentHeight;
3945
- const targetPixels = targetWidth * targetHeight;
3946
- const reductionFactor = currentPixels / targetPixels;
3947
- return Math.round(bitrate * reductionFactor);
3948
- }
3949
- return bitrate;
3950
- };
3951
- /**
3952
- * Browsers have different simulcast constraints for different video resolutions.
3953
- *
3954
- * This function modifies the provided list of video layers according to the
3955
- * current implementation of simulcast constraints in the Chromium based browsers.
3956
- *
3957
- * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
3958
- */
3959
- const withSimulcastConstraints = (settings, optimalVideoLayers) => {
3960
- let layers;
3961
- const size = Math.max(settings.width || 0, settings.height || 0);
3962
- if (size <= 320) {
3963
- // provide only one layer 320x240 (q), the one with the highest quality
3964
- layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
3965
- }
3966
- else if (size <= 640) {
3967
- // provide two layers, 160x120 (q) and 640x480 (h)
3968
- layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
3969
- }
3970
- else {
3971
- // provide three layers for sizes > 640x480
3972
- layers = optimalVideoLayers;
3973
- }
3974
- const ridMapping = ['q', 'h', 'f'];
3975
- return layers.map((layer, index) => ({
3976
- ...layer,
3977
- rid: ridMapping[index], // reassign rid
3978
- }));
3979
- };
3980
- const findOptimalScreenSharingLayers = (videoTrack, publishOptions, defaultMaxBitrate = 3000000) => {
3981
- const { screenShareSettings: preferences } = publishOptions || {};
3982
- const settings = videoTrack.getSettings();
3983
- return [
3984
- {
3985
- active: true,
3986
- rid: 'q', // single track, start from 'q'
3987
- width: settings.width || 0,
3988
- height: settings.height || 0,
3989
- scaleResolutionDownBy: 1,
3990
- maxBitrate: preferences?.maxBitrate ?? defaultMaxBitrate,
3991
- maxFramerate: preferences?.maxFramerate ?? 30,
3992
- },
3993
- ];
3994
- };
3995
-
3996
- const ensureExhausted = (x, message) => {
3997
- getLogger(['helpers'])('warn', message, x);
3998
- };
3999
-
4000
- const trackTypeToParticipantStreamKey = (trackType) => {
4001
- switch (trackType) {
4002
- case TrackType.SCREEN_SHARE:
4003
- return 'screenShareStream';
4004
- case TrackType.SCREEN_SHARE_AUDIO:
4005
- return 'screenShareAudioStream';
4006
- case TrackType.VIDEO:
4007
- return 'videoStream';
4008
- case TrackType.AUDIO:
4009
- return 'audioStream';
4010
- case TrackType.UNSPECIFIED:
4011
- throw new Error('Track type is unspecified');
4012
- default:
4013
- ensureExhausted(trackType, 'Unknown track type');
4014
- }
4015
- };
4016
- const muteTypeToTrackType = (muteType) => {
4017
- switch (muteType) {
4018
- case 'audio':
4019
- return TrackType.AUDIO;
4020
- case 'video':
4021
- return TrackType.VIDEO;
4022
- case 'screenshare':
4023
- return TrackType.SCREEN_SHARE;
4024
- case 'screenshare_audio':
4025
- return TrackType.SCREEN_SHARE_AUDIO;
4026
- default:
4027
- ensureExhausted(muteType, 'Unknown mute type');
4028
- }
4029
- };
4030
- const toTrackType = (trackType) => {
4031
- switch (trackType) {
4032
- case 'TRACK_TYPE_AUDIO':
4033
- return TrackType.AUDIO;
4034
- case 'TRACK_TYPE_VIDEO':
4035
- return TrackType.VIDEO;
4036
- case 'TRACK_TYPE_SCREEN_SHARE':
4037
- return TrackType.SCREEN_SHARE;
4038
- case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
4039
- return TrackType.SCREEN_SHARE_AUDIO;
4040
- default:
4041
- return undefined;
4042
- }
4043
3571
  };
4044
3572
 
4045
3573
  /**
@@ -5606,198 +5134,446 @@ class CallState {
5606
5134
  }
5607
5135
  }
5608
5136
 
5609
- const getRtpMap = (line) => {
5610
- // Example: a=rtpmap:110 opus/48000/2
5611
- const rtpRegex = /^a=rtpmap:(\d*) ([\w\-.]*)(?:\s*\/(\d*)(?:\s*\/(\S*))?)?/;
5612
- // The first captured group is the payload type number, the second captured group is the encoding name, the third captured group is the clock rate, and the fourth captured group is any additional parameters.
5613
- const rtpMatch = rtpRegex.exec(line);
5614
- if (rtpMatch) {
5615
- return {
5616
- original: rtpMatch[0],
5617
- payload: rtpMatch[1],
5618
- codec: rtpMatch[2],
5137
+ /**
5138
+ * A base class for the `Publisher` and `Subscriber` classes.
5139
+ * @internal
5140
+ */
5141
+ class BasePeerConnection {
5142
+ /**
5143
+ * Constructs a new `BasePeerConnection` instance.
5144
+ */
5145
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onUnrecoverableError, logTag, }) {
5146
+ this.isIceRestarting = false;
5147
+ this.subscriptions = [];
5148
+ /**
5149
+ * Disposes the `RTCPeerConnection` instance.
5150
+ */
5151
+ this.dispose = () => {
5152
+ this.detachEventHandlers();
5153
+ this.pc.close();
5154
+ };
5155
+ /**
5156
+ * Handles events synchronously.
5157
+ * Consecutive events are queued and executed one after the other.
5158
+ */
5159
+ this.on = (event, fn) => {
5160
+ this.subscriptions.push(this.dispatcher.on(event, (e) => {
5161
+ withoutConcurrency(`pc.${event}`, async () => fn(e)).catch((err) => {
5162
+ this.logger('warn', `Error handling ${event}`, err);
5163
+ });
5164
+ }));
5165
+ };
5166
+ /**
5167
+ * Appends the trickled ICE candidates to the `RTCPeerConnection`.
5168
+ */
5169
+ this.addTrickledIceCandidates = () => {
5170
+ const { iceTrickleBuffer } = this.sfuClient;
5171
+ const observable = this.peerType === PeerType.SUBSCRIBER
5172
+ ? iceTrickleBuffer.subscriberCandidates
5173
+ : iceTrickleBuffer.publisherCandidates;
5174
+ this.unsubscribeIceTrickle?.();
5175
+ this.unsubscribeIceTrickle = createSafeAsyncSubscription(observable, async (candidate) => {
5176
+ return this.pc.addIceCandidate(candidate).catch((e) => {
5177
+ this.logger('warn', `ICE candidate error`, e, candidate);
5178
+ });
5179
+ });
5180
+ };
5181
+ /**
5182
+ * Sets the SFU client to use.
5183
+ *
5184
+ * @param sfuClient the SFU client to use.
5185
+ */
5186
+ this.setSfuClient = (sfuClient) => {
5187
+ this.sfuClient = sfuClient;
5188
+ };
5189
+ /**
5190
+ * Returns the result of the `RTCPeerConnection.getStats()` method
5191
+ * @param selector an optional `MediaStreamTrack` to get the stats for.
5192
+ */
5193
+ this.getStats = (selector) => {
5194
+ return this.pc.getStats(selector);
5195
+ };
5196
+ /**
5197
+ * Handles the ICECandidate event and
5198
+ * Initiates an ICE Trickle process with the SFU.
5199
+ */
5200
+ this.onIceCandidate = (e) => {
5201
+ const { candidate } = e;
5202
+ if (!candidate) {
5203
+ this.logger('debug', 'null ice candidate');
5204
+ return;
5205
+ }
5206
+ const iceCandidate = this.toJSON(candidate);
5207
+ this.sfuClient
5208
+ .iceTrickle({ peerType: this.peerType, iceCandidate })
5209
+ .catch((err) => this.logger('warn', `ICETrickle failed`, err));
5210
+ };
5211
+ /**
5212
+ * Converts the ICE candidate to a JSON string.
5213
+ */
5214
+ this.toJSON = (candidate) => {
5215
+ if (!candidate.usernameFragment) {
5216
+ // react-native-webrtc doesn't include usernameFragment in the candidate
5217
+ const segments = candidate.candidate.split(' ');
5218
+ const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
5219
+ const usernameFragment = segments[ufragIndex];
5220
+ return JSON.stringify({ ...candidate, usernameFragment });
5221
+ }
5222
+ return JSON.stringify(candidate.toJSON());
5223
+ };
5224
+ /**
5225
+ * Handles the ICE connection state change event.
5226
+ */
5227
+ this.onIceConnectionStateChange = () => {
5228
+ const state = this.pc.iceConnectionState;
5229
+ this.logger('debug', `ICE connection state changed`, state);
5230
+ if (this.state.callingState === exports.CallingState.RECONNECTING)
5231
+ return;
5232
+ // do nothing when ICE is restarting
5233
+ if (this.isIceRestarting)
5234
+ return;
5235
+ if (state === 'failed' || state === 'disconnected') {
5236
+ this.logger('debug', `Attempting to restart ICE`);
5237
+ this.restartIce().catch((e) => {
5238
+ this.logger('error', `ICE restart failed`, e);
5239
+ this.onUnrecoverableError?.();
5240
+ });
5241
+ }
5242
+ };
5243
+ /**
5244
+ * Handles the ICE candidate error event.
5245
+ */
5246
+ this.onIceCandidateError = (e) => {
5247
+ const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
5248
+ `${e.errorCode}: ${e.errorText}`;
5249
+ const iceState = this.pc.iceConnectionState;
5250
+ const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
5251
+ this.logger(logLevel, `ICE Candidate error`, errorMessage);
5252
+ };
5253
+ /**
5254
+ * Handles the ICE gathering state change event.
5255
+ */
5256
+ this.onIceGatherChange = () => {
5257
+ this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
5258
+ };
5259
+ /**
5260
+ * Handles the signaling state change event.
5261
+ */
5262
+ this.onSignalingChange = () => {
5263
+ this.logger('debug', `Signaling state changed`, this.pc.signalingState);
5619
5264
  };
5265
+ this.peerType = peerType;
5266
+ this.sfuClient = sfuClient;
5267
+ this.state = state;
5268
+ this.dispatcher = dispatcher;
5269
+ this.onUnrecoverableError = onUnrecoverableError;
5270
+ this.logger = getLogger([
5271
+ peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
5272
+ logTag,
5273
+ ]);
5274
+ this.pc = new RTCPeerConnection(connectionConfig);
5275
+ this.pc.addEventListener('icecandidate', this.onIceCandidate);
5276
+ this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5277
+ this.pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5278
+ this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
5279
+ this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
5620
5280
  }
5621
- };
5622
- const getFmtp = (line) => {
5623
- // Example: a=fmtp:111 minptime=10; useinbandfec=1
5624
- const fmtpRegex = /^a=fmtp:(\d*) (.*)/;
5625
- const fmtpMatch = fmtpRegex.exec(line);
5626
- // The first captured group is the payload type number, the second captured group is any additional parameters.
5627
- if (fmtpMatch) {
5628
- return {
5629
- original: fmtpMatch[0],
5630
- payload: fmtpMatch[1],
5631
- config: fmtpMatch[2],
5281
+ /**
5282
+ * Detaches the event handlers from the `RTCPeerConnection`.
5283
+ */
5284
+ detachEventHandlers() {
5285
+ this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5286
+ this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5287
+ this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
5288
+ this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5289
+ this.pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
5290
+ this.unsubscribeIceTrickle?.();
5291
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
5292
+ }
5293
+ }
5294
+
5295
+ class TransceiverCache {
5296
+ constructor() {
5297
+ this.cache = [];
5298
+ this.layers = [];
5299
+ /**
5300
+ * An array maintaining the order how transceivers were added to the peer connection.
5301
+ * This is needed because some browsers (Firefox) don't reliably report
5302
+ * trackId and `mid` parameters.
5303
+ */
5304
+ this.transceiverOrder = [];
5305
+ /**
5306
+ * Adds a transceiver to the cache.
5307
+ */
5308
+ this.add = (publishOption, transceiver) => {
5309
+ this.cache.push({ publishOption, transceiver });
5310
+ this.transceiverOrder.push(transceiver);
5311
+ };
5312
+ /**
5313
+ * Gets the transceiver for the given publish option.
5314
+ */
5315
+ this.get = (publishOption) => {
5316
+ return this.findTransceiver(publishOption)?.transceiver;
5317
+ };
5318
+ /**
5319
+ * Gets the last transceiver for the given track type and publish option id.
5320
+ */
5321
+ this.getWith = (trackType, id) => {
5322
+ return this.findTransceiver({ trackType, id })?.transceiver;
5323
+ };
5324
+ /**
5325
+ * Checks if the cache has the given publish option.
5326
+ */
5327
+ this.has = (publishOption) => {
5328
+ return !!this.get(publishOption);
5329
+ };
5330
+ /**
5331
+ * Finds the first transceiver that satisfies the given predicate.
5332
+ */
5333
+ this.find = (predicate) => {
5334
+ return this.cache.find(predicate);
5335
+ };
5336
+ /**
5337
+ * Provides all the items in the cache.
5338
+ */
5339
+ this.items = () => {
5340
+ return this.cache;
5632
5341
  };
5342
+ /**
5343
+ * Init index of the transceiver in the cache.
5344
+ */
5345
+ this.indexOf = (transceiver) => {
5346
+ return this.transceiverOrder.indexOf(transceiver);
5347
+ };
5348
+ /**
5349
+ * Gets cached video layers for the given track.
5350
+ */
5351
+ this.getLayers = (publishOption) => {
5352
+ const entry = this.layers.find((item) => item.publishOption.id === publishOption.id &&
5353
+ item.publishOption.trackType === publishOption.trackType);
5354
+ return entry?.layers;
5355
+ };
5356
+ /**
5357
+ * Sets the video layers for the given track.
5358
+ */
5359
+ this.setLayers = (publishOption, layers = []) => {
5360
+ const entry = this.findLayer(publishOption);
5361
+ if (entry) {
5362
+ entry.layers = layers;
5363
+ }
5364
+ else {
5365
+ this.layers.push({ publishOption, layers });
5366
+ }
5367
+ };
5368
+ this.findTransceiver = (publishOption) => {
5369
+ return this.cache.find((item) => item.publishOption.id === publishOption.id &&
5370
+ item.publishOption.trackType === publishOption.trackType);
5371
+ };
5372
+ this.findLayer = (publishOption) => {
5373
+ return this.layers.find((item) => item.publishOption.id === publishOption.id &&
5374
+ item.publishOption.trackType === publishOption.trackType);
5375
+ };
5376
+ }
5377
+ }
5378
+
5379
+ const ensureExhausted = (x, message) => {
5380
+ getLogger(['helpers'])('warn', message, x);
5381
+ };
5382
+
5383
+ const trackTypeToParticipantStreamKey = (trackType) => {
5384
+ switch (trackType) {
5385
+ case TrackType.SCREEN_SHARE:
5386
+ return 'screenShareStream';
5387
+ case TrackType.SCREEN_SHARE_AUDIO:
5388
+ return 'screenShareAudioStream';
5389
+ case TrackType.VIDEO:
5390
+ return 'videoStream';
5391
+ case TrackType.AUDIO:
5392
+ return 'audioStream';
5393
+ case TrackType.UNSPECIFIED:
5394
+ throw new Error('Track type is unspecified');
5395
+ default:
5396
+ ensureExhausted(trackType, 'Unknown track type');
5397
+ }
5398
+ };
5399
+ const muteTypeToTrackType = (muteType) => {
5400
+ switch (muteType) {
5401
+ case 'audio':
5402
+ return TrackType.AUDIO;
5403
+ case 'video':
5404
+ return TrackType.VIDEO;
5405
+ case 'screenshare':
5406
+ return TrackType.SCREEN_SHARE;
5407
+ case 'screenshare_audio':
5408
+ return TrackType.SCREEN_SHARE_AUDIO;
5409
+ default:
5410
+ ensureExhausted(muteType, 'Unknown mute type');
5411
+ }
5412
+ };
5413
+ const toTrackType = (trackType) => {
5414
+ switch (trackType) {
5415
+ case 'TRACK_TYPE_AUDIO':
5416
+ return TrackType.AUDIO;
5417
+ case 'TRACK_TYPE_VIDEO':
5418
+ return TrackType.VIDEO;
5419
+ case 'TRACK_TYPE_SCREEN_SHARE':
5420
+ return TrackType.SCREEN_SHARE;
5421
+ case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
5422
+ return TrackType.SCREEN_SHARE_AUDIO;
5423
+ default:
5424
+ return undefined;
5633
5425
  }
5634
5426
  };
5427
+ const isAudioTrackType = (trackType) => trackType === TrackType.AUDIO || trackType === TrackType.SCREEN_SHARE_AUDIO;
5428
+
5429
+ const defaultBitratePerRid = {
5430
+ q: 300000,
5431
+ h: 750000,
5432
+ f: 1250000,
5433
+ };
5635
5434
  /**
5636
- * gets the media section for the specified media type.
5637
- * The media section contains the media type, port, codec, and payload type.
5638
- * Example: m=video 9 UDP/TLS/RTP/SAVPF 100 101 96 97 35 36 102 125 127
5435
+ * In SVC, we need to send only one video encoding (layer).
5436
+ * this layer will have the additional spatial and temporal layers
5437
+ * defined via the scalabilityMode property.
5438
+ *
5439
+ * @param layers the layers to process.
5639
5440
  */
5640
- const getMedia = (line, mediaType) => {
5641
- const regex = new RegExp(`(m=${mediaType} \\d+ [\\w/]+) ([\\d\\s]+)`);
5642
- const match = regex.exec(line);
5643
- if (match) {
5644
- return {
5645
- original: match[0],
5646
- mediaWithPorts: match[1],
5647
- codecOrder: match[2],
5648
- };
5649
- }
5441
+ const toSvcEncodings = (layers) => {
5442
+ if (!layers)
5443
+ return;
5444
+ // we take the highest quality layer, and we assign it to `q` encoder.
5445
+ const withRid = (rid) => (l) => l.rid === rid;
5446
+ const highestLayer = layers.find(withRid('f')) ||
5447
+ layers.find(withRid('h')) ||
5448
+ layers.find(withRid('q'));
5449
+ return [{ ...highestLayer, rid: 'q' }];
5650
5450
  };
5651
- const getMediaSection = (sdp, mediaType) => {
5652
- let media;
5653
- const rtpMap = [];
5654
- const fmtp = [];
5655
- let isTheRequiredMediaSection = false;
5656
- sdp.split(/(\r\n|\r|\n)/).forEach((line) => {
5657
- const isValidLine = /^([a-z])=(.*)/.test(line);
5658
- if (!isValidLine)
5659
- return;
5660
- /*
5661
- NOTE: according to https://www.rfc-editor.org/rfc/rfc8866.pdf
5662
- Each media description starts with an "m=" line and continues to the next media description or the end of the whole session description, whichever comes first
5663
- */
5664
- const type = line[0];
5665
- if (type === 'm') {
5666
- const _media = getMedia(line, mediaType);
5667
- isTheRequiredMediaSection = !!_media;
5668
- if (_media) {
5669
- media = _media;
5670
- }
5451
+ /**
5452
+ * Converts the rid to a video quality.
5453
+ */
5454
+ const ridToVideoQuality = (rid) => {
5455
+ return rid === 'q'
5456
+ ? VideoQuality.LOW_UNSPECIFIED
5457
+ : rid === 'h'
5458
+ ? VideoQuality.MID
5459
+ : VideoQuality.HIGH; // default to HIGH
5460
+ };
5461
+ /**
5462
+ * Converts the given video layers to SFU video layers.
5463
+ */
5464
+ const toVideoLayers = (layers = []) => {
5465
+ return layers.map((layer) => ({
5466
+ rid: layer.rid || '',
5467
+ bitrate: layer.maxBitrate || 0,
5468
+ fps: layer.maxFramerate || 0,
5469
+ quality: ridToVideoQuality(layer.rid || ''),
5470
+ videoDimension: { width: layer.width, height: layer.height },
5471
+ }));
5472
+ };
5473
+ /**
5474
+ * Converts the spatial and temporal layers to a scalability mode.
5475
+ */
5476
+ const toScalabilityMode = (spatialLayers, temporalLayers) => `L${spatialLayers}T${temporalLayers}${spatialLayers > 1 ? '_KEY' : ''}`;
5477
+ /**
5478
+ * Determines the most optimal video layers for the given track.
5479
+ *
5480
+ * @param videoTrack the video track to find optimal layers for.
5481
+ * @param publishOption the publish options for the track.
5482
+ */
5483
+ const computeVideoLayers = (videoTrack, publishOption) => {
5484
+ if (isAudioTrackType(publishOption.trackType))
5485
+ return;
5486
+ const optimalVideoLayers = [];
5487
+ const settings = videoTrack.getSettings();
5488
+ const { width = 0, height = 0 } = settings;
5489
+ const { bitrate, codec, fps, maxSpatialLayers = 3, maxTemporalLayers = 3, videoDimension = { width: 1280, height: 720 }, } = publishOption;
5490
+ const maxBitrate = getComputedMaxBitrate(videoDimension, width, height, bitrate);
5491
+ let downscaleFactor = 1;
5492
+ let bitrateFactor = 1;
5493
+ const svcCodec = isSvcCodec(codec?.name);
5494
+ for (const rid of ['f', 'h', 'q'].slice(0, maxSpatialLayers)) {
5495
+ const layer = {
5496
+ active: true,
5497
+ rid,
5498
+ width: Math.round(width / downscaleFactor),
5499
+ height: Math.round(height / downscaleFactor),
5500
+ maxBitrate: maxBitrate / bitrateFactor || defaultBitratePerRid[rid],
5501
+ maxFramerate: fps,
5502
+ };
5503
+ if (svcCodec) {
5504
+ // for SVC codecs, we need to set the scalability mode, and the
5505
+ // codec will handle the rest (layers, temporal layers, etc.)
5506
+ layer.scalabilityMode = toScalabilityMode(maxSpatialLayers, maxTemporalLayers);
5671
5507
  }
5672
- else if (isTheRequiredMediaSection && type === 'a') {
5673
- const rtpMapLine = getRtpMap(line);
5674
- const fmtpLine = getFmtp(line);
5675
- if (rtpMapLine) {
5676
- rtpMap.push(rtpMapLine);
5677
- }
5678
- else if (fmtpLine) {
5679
- fmtp.push(fmtpLine);
5680
- }
5508
+ else {
5509
+ // for non-SVC codecs, we need to downscale proportionally (simulcast)
5510
+ layer.scaleResolutionDownBy = downscaleFactor;
5681
5511
  }
5682
- });
5683
- if (media) {
5684
- return {
5685
- media,
5686
- rtpMap,
5687
- fmtp,
5688
- };
5512
+ downscaleFactor *= 2;
5513
+ bitrateFactor *= 2;
5514
+ // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
5515
+ // when deciding which layer to disable when CPU or bandwidth is constrained.
5516
+ // Encodings should be ordered in increasing spatial resolution order.
5517
+ optimalVideoLayers.unshift(layer);
5689
5518
  }
5519
+ // for simplicity, we start with all layers enabled, then this function
5520
+ // will clear/reassign the layers that are not needed
5521
+ return withSimulcastConstraints(settings, optimalVideoLayers);
5690
5522
  };
5691
5523
  /**
5692
- * Gets the fmtp line corresponding to opus
5524
+ * Computes the maximum bitrate for a given resolution.
5525
+ * If the current resolution is lower than the target resolution,
5526
+ * we want to proportionally reduce the target bitrate.
5527
+ * If the current resolution is higher than the target resolution,
5528
+ * we want to use the target bitrate.
5529
+ *
5530
+ * @param targetResolution the target resolution.
5531
+ * @param currentWidth the current width of the track.
5532
+ * @param currentHeight the current height of the track.
5533
+ * @param bitrate the target bitrate.
5693
5534
  */
5694
- const getOpusFmtp = (sdp) => {
5695
- const section = getMediaSection(sdp, 'audio');
5696
- const rtpMap = section?.rtpMap.find((r) => r.codec.toLowerCase() === 'opus');
5697
- const codecId = rtpMap?.payload;
5698
- if (codecId) {
5699
- return section?.fmtp.find((f) => f.payload === codecId);
5535
+ const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, bitrate) => {
5536
+ // if the current resolution is lower than the target resolution,
5537
+ // we want to proportionally reduce the target bitrate
5538
+ const { width: targetWidth, height: targetHeight } = targetResolution;
5539
+ if (currentWidth < targetWidth || currentHeight < targetHeight) {
5540
+ const currentPixels = currentWidth * currentHeight;
5541
+ const targetPixels = targetWidth * targetHeight;
5542
+ const reductionFactor = currentPixels / targetPixels;
5543
+ return Math.round(bitrate * reductionFactor);
5700
5544
  }
5545
+ return bitrate;
5701
5546
  };
5702
5547
  /**
5703
- * Returns an SDP with DTX enabled or disabled.
5704
- */
5705
- const toggleDtx = (sdp, enable) => {
5706
- const opusFmtp = getOpusFmtp(sdp);
5707
- if (!opusFmtp)
5708
- return sdp;
5709
- const matchDtx = /usedtx=(\d)/.exec(opusFmtp.config);
5710
- const requiredDtxConfig = `usedtx=${enable ? '1' : '0'}`;
5711
- const newFmtp = matchDtx
5712
- ? opusFmtp.original.replace(/usedtx=(\d)/, requiredDtxConfig)
5713
- : `${opusFmtp.original};${requiredDtxConfig}`;
5714
- return sdp.replace(opusFmtp.original, newFmtp);
5715
- };
5716
- /**
5717
- * Returns and SDP with all the codecs except the given codec removed.
5718
- */
5719
- const preserveCodec = (sdp, mid, codec) => {
5720
- const [kind, codecName] = codec.mimeType.toLowerCase().split('/');
5721
- const toSet = (fmtpLine) => new Set(fmtpLine.split(';').map((f) => f.trim().toLowerCase()));
5722
- const equal = (a, b) => {
5723
- if (a.size !== b.size)
5724
- return false;
5725
- for (const item of a)
5726
- if (!b.has(item))
5727
- return false;
5728
- return true;
5729
- };
5730
- const codecFmtp = toSet(codec.sdpFmtpLine || '');
5731
- const parsedSdp = SDP__namespace.parse(sdp);
5732
- for (const media of parsedSdp.media) {
5733
- if (media.type !== kind || String(media.mid) !== mid)
5734
- continue;
5735
- // find the payload id of the desired codec
5736
- const payloads = new Set();
5737
- for (const rtp of media.rtp) {
5738
- if (rtp.codec.toLowerCase() !== codecName)
5739
- continue;
5740
- const match =
5741
- // vp8 doesn't have any fmtp, we preserve it without any additional checks
5742
- codecName === 'vp8'
5743
- ? true
5744
- : media.fmtp.some((f) => f.payload === rtp.payload && equal(toSet(f.config), codecFmtp));
5745
- if (match) {
5746
- payloads.add(rtp.payload);
5747
- }
5748
- }
5749
- // find the corresponding rtx codec by matching apt=<preserved-codec-payload>
5750
- for (const fmtp of media.fmtp) {
5751
- const match = fmtp.config.match(/(apt)=(\d+)/);
5752
- if (!match)
5753
- continue;
5754
- const [, , preservedCodecPayload] = match;
5755
- if (payloads.has(Number(preservedCodecPayload))) {
5756
- payloads.add(fmtp.payload);
5757
- }
5758
- }
5759
- media.rtp = media.rtp.filter((r) => payloads.has(r.payload));
5760
- media.fmtp = media.fmtp.filter((f) => payloads.has(f.payload));
5761
- media.rtcpFb = media.rtcpFb?.filter((f) => payloads.has(f.payload));
5762
- media.payloads = Array.from(payloads).join(' ');
5763
- }
5764
- return SDP__namespace.write(parsedSdp);
5765
- };
5766
- /**
5767
- * Enables high-quality audio through SDP munging for the given trackMid.
5548
+ * Browsers have different simulcast constraints for different video resolutions.
5768
5549
  *
5769
- * @param sdp the SDP to munge.
5770
- * @param trackMid the trackMid.
5771
- * @param maxBitrate the max bitrate to set.
5772
- */
5773
- const enableHighQualityAudio = (sdp, trackMid, maxBitrate = 510000) => {
5774
- maxBitrate = Math.max(Math.min(maxBitrate, 510000), 96000);
5775
- const parsedSdp = SDP__namespace.parse(sdp);
5776
- const audioMedia = parsedSdp.media.find((m) => m.type === 'audio' && String(m.mid) === trackMid);
5777
- if (!audioMedia)
5778
- return sdp;
5779
- const opusRtp = audioMedia.rtp.find((r) => r.codec === 'opus');
5780
- if (!opusRtp)
5781
- return sdp;
5782
- const opusFmtp = audioMedia.fmtp.find((f) => f.payload === opusRtp.payload);
5783
- if (!opusFmtp)
5784
- return sdp;
5785
- // enable stereo, if not already enabled
5786
- if (opusFmtp.config.match(/stereo=(\d)/)) {
5787
- opusFmtp.config = opusFmtp.config.replace(/stereo=(\d)/, 'stereo=1');
5788
- }
5789
- else {
5790
- opusFmtp.config = `${opusFmtp.config};stereo=1`;
5550
+ * This function modifies the provided list of video layers according to the
5551
+ * current implementation of simulcast constraints in the Chromium based browsers.
5552
+ *
5553
+ * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
5554
+ */
5555
+ const withSimulcastConstraints = (settings, optimalVideoLayers) => {
5556
+ let layers;
5557
+ const size = Math.max(settings.width || 0, settings.height || 0);
5558
+ if (size <= 320) {
5559
+ // provide only one layer 320x240 (q), the one with the highest quality
5560
+ layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
5791
5561
  }
5792
- // set maxaveragebitrate, to the given value
5793
- if (opusFmtp.config.match(/maxaveragebitrate=(\d*)/)) {
5794
- opusFmtp.config = opusFmtp.config.replace(/maxaveragebitrate=(\d*)/, `maxaveragebitrate=${maxBitrate}`);
5562
+ else if (size <= 640) {
5563
+ // provide two layers, 160x120 (q) and 640x480 (h)
5564
+ layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
5795
5565
  }
5796
5566
  else {
5797
- opusFmtp.config = `${opusFmtp.config};maxaveragebitrate=${maxBitrate}`;
5567
+ // provide three layers for sizes > 640x480
5568
+ layers = optimalVideoLayers;
5798
5569
  }
5799
- return SDP__namespace.write(parsedSdp);
5570
+ const ridMapping = ['q', 'h', 'f'];
5571
+ return layers.map((layer, index) => ({
5572
+ ...layer,
5573
+ rid: ridMapping[index], // reassign rid
5574
+ }));
5800
5575
  };
5576
+
5801
5577
  /**
5802
5578
  * Extracts the mid from the transceiver or the SDP.
5803
5579
  *
@@ -5809,9 +5585,9 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5809
5585
  if (transceiver.mid)
5810
5586
  return transceiver.mid;
5811
5587
  if (!sdp)
5812
- return '';
5588
+ return String(transceiverInitIndex);
5813
5589
  const track = transceiver.sender.track;
5814
- const parsedSdp = SDP__namespace.parse(sdp);
5590
+ const parsedSdp = sdpTransform.parse(sdp);
5815
5591
  const media = parsedSdp.media.find((m) => {
5816
5592
  return (m.type === track.kind &&
5817
5593
  // if `msid` is not present, we assume that the track is the first one
@@ -5819,7 +5595,7 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5819
5595
  });
5820
5596
  if (typeof media?.mid !== 'undefined')
5821
5597
  return String(media.mid);
5822
- if (transceiverInitIndex === -1)
5598
+ if (transceiverInitIndex < 0)
5823
5599
  return '';
5824
5600
  return String(transceiverInitIndex);
5825
5601
  };
@@ -5829,164 +5605,87 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5829
5605
  *
5830
5606
  * @internal
5831
5607
  */
5832
- class Publisher {
5608
+ class Publisher extends BasePeerConnection {
5833
5609
  /**
5834
5610
  * Constructs a new `Publisher` instance.
5835
5611
  */
5836
- constructor({ connectionConfig, sfuClient, dispatcher, state, isDtxEnabled, isRedEnabled, onUnrecoverableError, logTag, }) {
5837
- this.transceiverCache = new Map();
5838
- this.trackLayersCache = new Map();
5839
- this.publishOptsForTrack = new Map();
5840
- /**
5841
- * An array maintaining the order how transceivers were added to the peer connection.
5842
- * This is needed because some browsers (Firefox) don't reliably report
5843
- * trackId and `mid` parameters.
5844
- *
5845
- * @internal
5846
- */
5847
- this.transceiverInitOrder = [];
5848
- this.isIceRestarting = false;
5849
- this.createPeerConnection = (connectionConfig) => {
5850
- const pc = new RTCPeerConnection(connectionConfig);
5851
- pc.addEventListener('icecandidate', this.onIceCandidate);
5852
- pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5853
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5854
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5855
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5856
- pc.addEventListener('signalingstatechange', this.onSignalingStateChange);
5857
- return pc;
5858
- };
5859
- /**
5860
- * Closes the publisher PeerConnection and cleans up the resources.
5861
- */
5862
- this.close = ({ stopTracks }) => {
5863
- if (stopTracks) {
5864
- this.stopPublishing();
5865
- this.transceiverCache.clear();
5866
- this.trackLayersCache.clear();
5867
- }
5868
- this.detachEventHandlers();
5869
- this.pc.close();
5870
- };
5871
- /**
5872
- * Detaches the event handlers from the `RTCPeerConnection`.
5873
- * This is useful when we want to replace the `RTCPeerConnection`
5874
- * instance with a new one (in case of migration).
5875
- */
5876
- this.detachEventHandlers = () => {
5877
- this.unsubscribeOnIceRestart();
5878
- this.unsubscribeChangePublishQuality();
5879
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5880
- this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5881
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5882
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5883
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5884
- this.pc.removeEventListener('signalingstatechange', this.onSignalingStateChange);
5885
- };
5612
+ constructor({ publishOptions, ...baseOptions }) {
5613
+ super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
5614
+ this.transceiverCache = new TransceiverCache();
5886
5615
  /**
5887
5616
  * Starts publishing the given track of the given media stream.
5888
5617
  *
5889
5618
  * Consecutive calls to this method will replace the stream.
5890
5619
  * The previous stream will be stopped.
5891
5620
  *
5892
- * @param mediaStream the media stream to publish.
5893
5621
  * @param track the track to publish.
5894
5622
  * @param trackType the track type to publish.
5895
- * @param opts the optional publish options to use.
5896
5623
  */
5897
- this.publishStream = async (mediaStream, track, trackType, opts = {}) => {
5898
- if (track.readyState === 'ended') {
5899
- throw new Error(`Can't publish a track that has ended already.`);
5900
- }
5901
- // enable the track if it is disabled
5902
- if (!track.enabled)
5903
- track.enabled = true;
5904
- const transceiver = this.transceiverCache.get(trackType);
5905
- if (!transceiver || !transceiver.sender.track) {
5906
- // listen for 'ended' event on the track as it might be ended abruptly
5907
- // by an external factors such as permission revokes, a disconnected device, etc.
5908
- // keep in mind that `track.stop()` doesn't trigger this event.
5909
- const handleTrackEnded = () => {
5910
- this.logger('info', `Track ${TrackType[trackType]} has ended abruptly`);
5911
- track.removeEventListener('ended', handleTrackEnded);
5912
- this.notifyTrackMuteStateChanged(mediaStream, trackType, true).catch((err) => this.logger('warn', `Couldn't notify track mute state`, err));
5913
- };
5914
- track.addEventListener('ended', handleTrackEnded);
5915
- this.addTransceiver(trackType, track, opts, mediaStream);
5624
+ this.publish = async (track, trackType) => {
5625
+ if (!this.publishOptions.some((o) => o.trackType === trackType)) {
5626
+ throw new Error(`No publish options found for ${TrackType[trackType]}`);
5916
5627
  }
5917
- else {
5918
- await this.updateTransceiver(transceiver, track);
5628
+ for (const publishOption of this.publishOptions) {
5629
+ if (publishOption.trackType !== trackType)
5630
+ continue;
5631
+ // create a clone of the track as otherwise the same trackId will
5632
+ // appear in the SDP in multiple transceivers
5633
+ const trackToPublish = track.clone();
5634
+ const transceiver = this.transceiverCache.get(publishOption);
5635
+ if (!transceiver) {
5636
+ this.addTransceiver(trackToPublish, publishOption);
5637
+ }
5638
+ else {
5639
+ await transceiver.sender.replaceTrack(trackToPublish);
5640
+ }
5919
5641
  }
5920
- await this.notifyTrackMuteStateChanged(mediaStream, trackType, false);
5921
5642
  };
5922
5643
  /**
5923
- * Adds a new transceiver to the peer connection.
5924
- * This needs to be called when a new track kind is added to the peer connection.
5925
- * In other cases, use `updateTransceiver` method.
5644
+ * Adds a new transceiver carrying the given track to the peer connection.
5926
5645
  */
5927
- this.addTransceiver = (trackType, track, opts, mediaStream) => {
5928
- const { forceCodec, preferredCodec } = opts;
5929
- const codecInUse = forceCodec || getOptimalVideoCodec(preferredCodec);
5930
- const videoEncodings = this.computeLayers(trackType, track, opts);
5646
+ this.addTransceiver = (track, publishOption) => {
5647
+ const videoEncodings = computeVideoLayers(track, publishOption);
5648
+ const sendEncodings = isSvcCodec(publishOption.codec?.name)
5649
+ ? toSvcEncodings(videoEncodings)
5650
+ : videoEncodings;
5931
5651
  const transceiver = this.pc.addTransceiver(track, {
5932
5652
  direction: 'sendonly',
5933
- streams: trackType === TrackType.VIDEO || trackType === TrackType.SCREEN_SHARE
5934
- ? [mediaStream]
5935
- : undefined,
5936
- sendEncodings: isSvcCodec(codecInUse)
5937
- ? toSvcEncodings(videoEncodings)
5938
- : videoEncodings,
5653
+ sendEncodings,
5939
5654
  });
5655
+ const trackType = publishOption.trackType;
5940
5656
  this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
5941
- this.transceiverInitOrder.push(trackType);
5942
- this.transceiverCache.set(trackType, transceiver);
5943
- this.publishOptsForTrack.set(trackType, opts);
5944
- // handle codec preferences
5945
- if (!('setCodecPreferences' in transceiver))
5946
- return;
5947
- const codecPreferences = this.getCodecPreferences(trackType, trackType === TrackType.VIDEO ? codecInUse : undefined, 'receiver');
5948
- if (!codecPreferences)
5949
- return;
5950
- try {
5951
- this.logger('info', `Setting ${TrackType[trackType]} codec preferences`, codecPreferences);
5952
- transceiver.setCodecPreferences(codecPreferences);
5953
- }
5954
- catch (err) {
5955
- this.logger('warn', `Couldn't set codec preferences`, err);
5956
- }
5957
- };
5958
- /**
5959
- * Updates the given transceiver with the new track.
5960
- * Stops the previous track and replaces it with the new one.
5961
- */
5962
- this.updateTransceiver = async (transceiver, track) => {
5963
- const previousTrack = transceiver.sender.track;
5964
- // don't stop the track if we are re-publishing the same track
5965
- if (previousTrack && previousTrack !== track) {
5966
- previousTrack.stop();
5967
- }
5968
- await transceiver.sender.replaceTrack(track);
5657
+ this.transceiverCache.add(publishOption, transceiver);
5969
5658
  };
5970
5659
  /**
5971
- * Stops publishing the given track type to the SFU, if it is currently being published.
5972
- * Underlying track will be stopped and removed from the publisher.
5973
- * @param trackType the track type to unpublish.
5974
- * @param stopTrack specifies whether track should be stopped or just disabled
5660
+ * Synchronizes the current Publisher state with the provided publish options.
5975
5661
  */
5976
- this.unpublishStream = async (trackType, stopTrack) => {
5977
- const transceiver = this.transceiverCache.get(trackType);
5978
- if (transceiver &&
5979
- transceiver.sender.track &&
5980
- (stopTrack
5981
- ? transceiver.sender.track.readyState === 'live'
5982
- : transceiver.sender.track.enabled)) {
5983
- stopTrack
5984
- ? transceiver.sender.track.stop()
5985
- : (transceiver.sender.track.enabled = false);
5986
- // We don't need to notify SFU if unpublishing in response to remote soft mute
5987
- if (this.state.localParticipant?.publishedTracks.includes(trackType)) {
5988
- await this.notifyTrackMuteStateChanged(undefined, trackType, true);
5989
- }
5662
+ this.syncPublishOptions = async () => {
5663
+ // enable publishing with new options -> [av1, vp9]
5664
+ for (const publishOption of this.publishOptions) {
5665
+ const { trackType } = publishOption;
5666
+ if (!this.isPublishing(trackType))
5667
+ continue;
5668
+ if (this.transceiverCache.has(publishOption))
5669
+ continue;
5670
+ const item = this.transceiverCache.find((i) => !!i.transceiver.sender.track &&
5671
+ i.publishOption.trackType === trackType);
5672
+ if (!item || !item.transceiver)
5673
+ continue;
5674
+ // take the track from the existing transceiver for the same track type,
5675
+ // clone it and publish it with the new publish options
5676
+ const track = item.transceiver.sender.track.clone();
5677
+ this.addTransceiver(track, publishOption);
5678
+ }
5679
+ // stop publishing with options not required anymore -> [vp9]
5680
+ for (const item of this.transceiverCache.items()) {
5681
+ const { publishOption, transceiver } = item;
5682
+ const hasPublishOption = this.publishOptions.some((option) => option.id === publishOption.id &&
5683
+ option.trackType === publishOption.trackType);
5684
+ if (hasPublishOption)
5685
+ continue;
5686
+ // it is safe to stop the track here, it is a clone
5687
+ transceiver.sender.track?.stop();
5688
+ await transceiver.sender.replaceTrack(null);
5990
5689
  }
5991
5690
  };
5992
5691
  /**
@@ -5995,57 +5694,52 @@ class Publisher {
5995
5694
  * @param trackType the track type to check.
5996
5695
  */
5997
5696
  this.isPublishing = (trackType) => {
5998
- const transceiver = this.transceiverCache.get(trackType);
5999
- if (!transceiver || !transceiver.sender)
6000
- return false;
6001
- const track = transceiver.sender.track;
6002
- return !!track && track.readyState === 'live' && track.enabled;
6003
- };
6004
- this.notifyTrackMuteStateChanged = async (mediaStream, trackType, isMuted) => {
6005
- await this.sfuClient.updateMuteState(trackType, isMuted);
6006
- const audioOrVideoOrScreenShareStream = trackTypeToParticipantStreamKey(trackType);
6007
- if (!audioOrVideoOrScreenShareStream)
6008
- return;
6009
- if (isMuted) {
6010
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => ({
6011
- publishedTracks: p.publishedTracks.filter((t) => t !== trackType),
6012
- [audioOrVideoOrScreenShareStream]: undefined,
6013
- }));
6014
- }
6015
- else {
6016
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => {
6017
- return {
6018
- publishedTracks: p.publishedTracks.includes(trackType)
6019
- ? p.publishedTracks
6020
- : [...p.publishedTracks, trackType],
6021
- [audioOrVideoOrScreenShareStream]: mediaStream,
6022
- };
6023
- });
5697
+ for (const item of this.transceiverCache.items()) {
5698
+ if (item.publishOption.trackType !== trackType)
5699
+ continue;
5700
+ const track = item.transceiver.sender.track;
5701
+ if (!track)
5702
+ continue;
5703
+ if (track.readyState === 'live' && track.enabled)
5704
+ return true;
6024
5705
  }
5706
+ return false;
6025
5707
  };
6026
5708
  /**
6027
- * Stops publishing all tracks and stop all tracks.
5709
+ * Maps the given track ID to the corresponding track type.
6028
5710
  */
6029
- this.stopPublishing = () => {
6030
- this.logger('debug', 'Stopping publishing all tracks');
6031
- this.pc.getSenders().forEach((s) => {
6032
- s.track?.stop();
6033
- if (this.pc.signalingState !== 'closed') {
6034
- this.pc.removeTrack(s);
5711
+ this.getTrackType = (trackId) => {
5712
+ for (const transceiverId of this.transceiverCache.items()) {
5713
+ const { publishOption, transceiver } = transceiverId;
5714
+ if (transceiver.sender.track?.id === trackId) {
5715
+ return publishOption.trackType;
6035
5716
  }
6036
- });
5717
+ }
5718
+ return undefined;
6037
5719
  };
6038
- this.changePublishQuality = async (enabledLayers) => {
6039
- this.logger('info', 'Update publish quality, requested layers by SFU:', enabledLayers);
6040
- const videoSender = this.transceiverCache.get(TrackType.VIDEO)?.sender;
6041
- if (!videoSender) {
6042
- this.logger('warn', 'Update publish quality, no video sender found.');
6043
- return;
5720
+ /**
5721
+ * Stops the cloned track that is being published to the SFU.
5722
+ */
5723
+ this.stopTracks = (...trackTypes) => {
5724
+ for (const item of this.transceiverCache.items()) {
5725
+ const { publishOption, transceiver } = item;
5726
+ if (!trackTypes.includes(publishOption.trackType))
5727
+ continue;
5728
+ transceiver.sender.track?.stop();
5729
+ }
5730
+ };
5731
+ this.changePublishQuality = async (videoSender) => {
5732
+ const { trackType, layers, publishOptionId } = videoSender;
5733
+ const enabledLayers = layers.filter((l) => l.active);
5734
+ const tag = 'Update publish quality:';
5735
+ this.logger('info', `${tag} requested layers by SFU:`, enabledLayers);
5736
+ const sender = this.transceiverCache.getWith(trackType, publishOptionId)?.sender;
5737
+ if (!sender) {
5738
+ return this.logger('warn', `${tag} no video sender found.`);
6044
5739
  }
6045
- const params = videoSender.getParameters();
5740
+ const params = sender.getParameters();
6046
5741
  if (params.encodings.length === 0) {
6047
- this.logger('warn', 'Update publish quality, No suitable video encoding quality found');
6048
- return;
5742
+ return this.logger('warn', `${tag} there are no encodings set.`);
6049
5743
  }
6050
5744
  const [codecInUse] = params.codecs;
6051
5745
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse.mimeType);
@@ -6087,54 +5781,12 @@ class Publisher {
6087
5781
  changed = true;
6088
5782
  }
6089
5783
  }
6090
- const activeLayers = params.encodings.filter((e) => e.active);
5784
+ const activeEncoders = params.encodings.filter((e) => e.active);
6091
5785
  if (!changed) {
6092
- this.logger('info', `Update publish quality, no change:`, activeLayers);
6093
- return;
6094
- }
6095
- await videoSender.setParameters(params);
6096
- this.logger('info', `Update publish quality, enabled rids:`, activeLayers);
6097
- };
6098
- /**
6099
- * Returns the result of the `RTCPeerConnection.getStats()` method
6100
- * @param selector
6101
- * @returns
6102
- */
6103
- this.getStats = (selector) => {
6104
- return this.pc.getStats(selector);
6105
- };
6106
- this.getCodecPreferences = (trackType, preferredCodec, codecPreferencesSource) => {
6107
- if (trackType === TrackType.VIDEO) {
6108
- return getPreferredCodecs('video', preferredCodec || 'vp8', undefined, codecPreferencesSource);
6109
- }
6110
- if (trackType === TrackType.AUDIO) {
6111
- const defaultAudioCodec = this.isRedEnabled ? 'red' : 'opus';
6112
- const codecToRemove = !this.isRedEnabled ? 'red' : undefined;
6113
- return getPreferredCodecs('audio', preferredCodec ?? defaultAudioCodec, codecToRemove, codecPreferencesSource);
6114
- }
6115
- };
6116
- this.onIceCandidate = (e) => {
6117
- const { candidate } = e;
6118
- if (!candidate) {
6119
- this.logger('debug', 'null ice candidate');
6120
- return;
5786
+ return this.logger('info', `${tag} no change:`, activeEncoders);
6121
5787
  }
6122
- this.sfuClient
6123
- .iceTrickle({
6124
- iceCandidate: getIceCandidate(candidate),
6125
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
6126
- })
6127
- .catch((err) => {
6128
- this.logger('warn', `ICETrickle failed`, err);
6129
- });
6130
- };
6131
- /**
6132
- * Sets the SFU client to use.
6133
- *
6134
- * @param sfuClient the SFU client to use.
6135
- */
6136
- this.setSfuClient = (sfuClient) => {
6137
- this.sfuClient = sfuClient;
5788
+ await sender.setParameters(params);
5789
+ this.logger('info', `${tag} enabled rids:`, activeEncoders);
6138
5790
  };
6139
5791
  /**
6140
5792
  * Restarts the ICE connection and renegotiates with the SFU.
@@ -6149,7 +5801,7 @@ class Publisher {
6149
5801
  await this.negotiate({ iceRestart: true });
6150
5802
  };
6151
5803
  this.onNegotiationNeeded = () => {
6152
- this.negotiate().catch((err) => {
5804
+ withoutConcurrency('publisher.negotiate', () => this.negotiate()).catch((err) => {
6153
5805
  this.logger('error', `Negotiation failed.`, err);
6154
5806
  this.onUnrecoverableError?.();
6155
5807
  });
@@ -6161,18 +5813,6 @@ class Publisher {
6161
5813
  */
6162
5814
  this.negotiate = async (options) => {
6163
5815
  const offer = await this.pc.createOffer(options);
6164
- if (offer.sdp) {
6165
- offer.sdp = toggleDtx(offer.sdp, this.isDtxEnabled);
6166
- if (this.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
6167
- offer.sdp = this.enableHighQualityAudio(offer.sdp);
6168
- }
6169
- if (this.isPublishing(TrackType.VIDEO)) {
6170
- // Hotfix for platforms that don't respect the ordered codec list
6171
- // (Firefox, Android, Linux, etc...).
6172
- // We remove all the codecs from the SDP except the one we want to use.
6173
- offer.sdp = this.removeUnpreferredCodecs(offer.sdp, TrackType.VIDEO);
6174
- }
6175
- }
6176
5816
  const trackInfos = this.getAnnouncedTracks(offer.sdp);
6177
5817
  if (trackInfos.length === 0) {
6178
5818
  throw new Error(`Can't negotiate without announcing any tracks`);
@@ -6191,238 +5831,121 @@ class Publisher {
6191
5831
  finally {
6192
5832
  this.isIceRestarting = false;
6193
5833
  }
6194
- this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(async (candidate) => {
6195
- try {
6196
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6197
- await this.pc.addIceCandidate(iceCandidate);
6198
- }
6199
- catch (e) {
6200
- this.logger('warn', `ICE candidate error`, e, candidate);
6201
- }
6202
- });
6203
- };
6204
- this.enableHighQualityAudio = (sdp) => {
6205
- const transceiver = this.transceiverCache.get(TrackType.SCREEN_SHARE_AUDIO);
6206
- if (!transceiver)
6207
- return sdp;
6208
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(TrackType.SCREEN_SHARE_AUDIO);
6209
- const mid = extractMid(transceiver, transceiverInitIndex, sdp);
6210
- return enableHighQualityAudio(sdp, mid);
5834
+ this.addTrickledIceCandidates();
6211
5835
  };
6212
5836
  /**
6213
5837
  * Returns a list of tracks that are currently being published.
6214
- *
6215
- * @internal
6216
- * @param sdp an optional SDP to extract the `mid` from.
6217
5838
  */
6218
- this.getAnnouncedTracks = (sdp) => {
6219
- sdp = sdp || this.pc.localDescription?.sdp;
6220
- return this.pc
6221
- .getTransceivers()
6222
- .filter((t) => t.direction === 'sendonly' && t.sender.track)
6223
- .map((transceiver) => {
6224
- let trackType;
6225
- this.transceiverCache.forEach((value, key) => {
6226
- if (value === transceiver)
6227
- trackType = key;
6228
- });
5839
+ this.getPublishedTracks = () => {
5840
+ const tracks = [];
5841
+ for (const { transceiver } of this.transceiverCache.items()) {
6229
5842
  const track = transceiver.sender.track;
6230
- let optimalLayers;
6231
- const isTrackLive = track.readyState === 'live';
6232
- if (isTrackLive) {
6233
- optimalLayers = this.computeLayers(trackType, track) || [];
6234
- this.trackLayersCache.set(trackType, optimalLayers);
6235
- }
6236
- else {
6237
- // we report the last known optimal layers for ended tracks
6238
- optimalLayers = this.trackLayersCache.get(trackType) || [];
6239
- this.logger('debug', `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`, optimalLayers);
6240
- }
6241
- const layers = optimalLayers.map((optimalLayer) => ({
6242
- rid: optimalLayer.rid || '',
6243
- bitrate: optimalLayer.maxBitrate || 0,
6244
- fps: optimalLayer.maxFramerate || 0,
6245
- quality: ridToVideoQuality(optimalLayer.rid || ''),
6246
- videoDimension: {
6247
- width: optimalLayer.width,
6248
- height: optimalLayer.height,
6249
- },
6250
- }));
6251
- const isAudioTrack = [
6252
- TrackType.AUDIO,
6253
- TrackType.SCREEN_SHARE_AUDIO,
6254
- ].includes(trackType);
6255
- const trackSettings = track.getSettings();
6256
- const isStereo = isAudioTrack && trackSettings.channelCount === 2;
6257
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(trackType);
6258
- return {
6259
- trackId: track.id,
6260
- layers: layers,
6261
- trackType,
6262
- mid: extractMid(transceiver, transceiverInitIndex, sdp),
6263
- stereo: isStereo,
6264
- dtx: isAudioTrack && this.isDtxEnabled,
6265
- red: isAudioTrack && this.isRedEnabled,
6266
- muted: !isTrackLive,
6267
- };
6268
- });
6269
- };
6270
- this.computeLayers = (trackType, track, opts) => {
6271
- const { settings } = this.state;
6272
- const targetResolution = settings?.video
6273
- .target_resolution;
6274
- const screenShareBitrate = settings?.screensharing.target_resolution?.bitrate;
6275
- const publishOpts = opts || this.publishOptsForTrack.get(trackType);
6276
- const codecInUse = opts?.forceCodec || getOptimalVideoCodec(opts?.preferredCodec);
6277
- return trackType === TrackType.VIDEO
6278
- ? findOptimalVideoLayers(track, targetResolution, codecInUse, publishOpts)
6279
- : trackType === TrackType.SCREEN_SHARE
6280
- ? findOptimalScreenSharingLayers(track, publishOpts, screenShareBitrate)
6281
- : undefined;
6282
- };
6283
- this.onIceCandidateError = (e) => {
6284
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6285
- `${e.errorCode}: ${e.errorText}`;
6286
- const iceState = this.pc.iceConnectionState;
6287
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6288
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6289
- };
6290
- this.onIceConnectionStateChange = () => {
6291
- const state = this.pc.iceConnectionState;
6292
- this.logger('debug', `ICE Connection state changed to`, state);
6293
- if (this.state.callingState === exports.CallingState.RECONNECTING)
6294
- return;
6295
- if (state === 'failed' || state === 'disconnected') {
6296
- this.logger('debug', `Attempting to restart ICE`);
6297
- this.restartIce().catch((e) => {
6298
- this.logger('error', `ICE restart error`, e);
6299
- this.onUnrecoverableError?.();
6300
- });
5843
+ if (track && track.readyState === 'live')
5844
+ tracks.push(track);
6301
5845
  }
6302
- };
6303
- this.onIceGatheringStateChange = () => {
6304
- this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
6305
- };
6306
- this.onSignalingStateChange = () => {
6307
- this.logger('debug', `Signaling state changed`, this.pc.signalingState);
6308
- };
6309
- this.logger = getLogger(['Publisher', logTag]);
6310
- this.pc = this.createPeerConnection(connectionConfig);
6311
- this.sfuClient = sfuClient;
6312
- this.state = state;
6313
- this.isDtxEnabled = isDtxEnabled;
6314
- this.isRedEnabled = isRedEnabled;
6315
- this.onUnrecoverableError = onUnrecoverableError;
6316
- this.unsubscribeOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6317
- if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
6318
- return;
6319
- this.restartIce().catch((err) => {
6320
- this.logger('warn', `ICERestart failed`, err);
6321
- this.onUnrecoverableError?.();
6322
- });
6323
- });
6324
- this.unsubscribeChangePublishQuality = dispatcher.on('changePublishQuality', ({ videoSenders }) => {
6325
- withoutConcurrency('publisher.changePublishQuality', async () => {
6326
- for (const videoSender of videoSenders) {
6327
- const { layers } = videoSender;
6328
- const enabledLayers = layers.filter((l) => l.active);
6329
- await this.changePublishQuality(enabledLayers);
6330
- }
6331
- }).catch((err) => {
6332
- this.logger('warn', 'Failed to change publish quality', err);
6333
- });
6334
- });
6335
- }
6336
- removeUnpreferredCodecs(sdp, trackType) {
6337
- const opts = this.publishOptsForTrack.get(trackType);
6338
- const forceSingleCodec = !!opts?.forceSingleCodec || isReactNative() || isFirefox();
6339
- if (!opts || !forceSingleCodec)
6340
- return sdp;
6341
- const codec = opts.forceCodec || getOptimalVideoCodec(opts.preferredCodec);
6342
- const orderedCodecs = this.getCodecPreferences(trackType, codec, 'sender');
6343
- if (!orderedCodecs || orderedCodecs.length === 0)
6344
- return sdp;
6345
- const transceiver = this.transceiverCache.get(trackType);
6346
- if (!transceiver)
6347
- return sdp;
6348
- const index = this.transceiverInitOrder.indexOf(trackType);
6349
- const mid = extractMid(transceiver, index, sdp);
6350
- const [codecToPreserve] = orderedCodecs;
6351
- return preserveCodec(sdp, mid, codecToPreserve);
6352
- }
6353
- }
6354
-
6355
- /**
6356
- * A wrapper around the `RTCPeerConnection` that handles the incoming
6357
- * media streams from the SFU.
6358
- *
6359
- * @internal
6360
- */
6361
- class Subscriber {
6362
- /**
6363
- * Constructs a new `Subscriber` instance.
6364
- *
6365
- * @param sfuClient the SFU client to use.
6366
- * @param dispatcher the dispatcher to use.
6367
- * @param state the state of the call.
6368
- * @param connectionConfig the connection configuration to use.
6369
- * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
6370
- * @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
6371
- * @param logTag a tag to use for logging.
6372
- */
6373
- constructor({ sfuClient, dispatcher, state, connectionConfig, onUnrecoverableError, logTag, }) {
6374
- this.isIceRestarting = false;
6375
- /**
6376
- * Creates a new `RTCPeerConnection` instance with the given configuration.
6377
- *
6378
- * @param connectionConfig the connection configuration to use.
6379
- */
6380
- this.createPeerConnection = (connectionConfig) => {
6381
- const pc = new RTCPeerConnection(connectionConfig);
6382
- pc.addEventListener('icecandidate', this.onIceCandidate);
6383
- pc.addEventListener('track', this.handleOnTrack);
6384
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6385
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6386
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
6387
- return pc;
5846
+ return tracks;
6388
5847
  };
6389
5848
  /**
6390
- * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
6391
- */
6392
- this.close = () => {
6393
- this.detachEventHandlers();
6394
- this.pc.close();
6395
- };
6396
- /**
6397
- * Detaches the event handlers from the `RTCPeerConnection`.
6398
- * This is useful when we want to replace the `RTCPeerConnection`
6399
- * instance with a new one (in case of migration).
5849
+ * Returns a list of tracks that are currently being published.
5850
+ * @param sdp an optional SDP to extract the `mid` from.
6400
5851
  */
6401
- this.detachEventHandlers = () => {
6402
- this.unregisterOnSubscriberOffer();
6403
- this.unregisterOnIceRestart();
6404
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
6405
- this.pc.removeEventListener('track', this.handleOnTrack);
6406
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6407
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6408
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5852
+ this.getAnnouncedTracks = (sdp) => {
5853
+ const trackInfos = [];
5854
+ for (const bundle of this.transceiverCache.items()) {
5855
+ const { transceiver, publishOption } = bundle;
5856
+ const track = transceiver.sender.track;
5857
+ if (!track)
5858
+ continue;
5859
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5860
+ }
5861
+ return trackInfos;
6409
5862
  };
6410
5863
  /**
6411
- * Returns the result of the `RTCPeerConnection.getStats()` method
6412
- * @param selector
6413
- * @returns
6414
- */
6415
- this.getStats = (selector) => {
6416
- return this.pc.getStats(selector);
5864
+ * Returns a list of tracks that are currently being published.
5865
+ * This method shall be used for the reconnection flow.
5866
+ * There we shouldn't announce the tracks that have been stopped due to a codec switch.
5867
+ */
5868
+ this.getAnnouncedTracksForReconnect = () => {
5869
+ const sdp = this.pc.localDescription?.sdp;
5870
+ const trackInfos = [];
5871
+ for (const publishOption of this.publishOptions) {
5872
+ const transceiver = this.transceiverCache.get(publishOption);
5873
+ if (!transceiver || !transceiver.sender.track)
5874
+ continue;
5875
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5876
+ }
5877
+ return trackInfos;
6417
5878
  };
6418
5879
  /**
6419
- * Sets the SFU client to use.
6420
- *
6421
- * @param sfuClient the SFU client to use.
5880
+ * Converts the given transceiver to a `TrackInfo` object.
6422
5881
  */
6423
- this.setSfuClient = (sfuClient) => {
6424
- this.sfuClient = sfuClient;
5882
+ this.toTrackInfo = (transceiver, publishOption, sdp) => {
5883
+ const track = transceiver.sender.track;
5884
+ const isTrackLive = track.readyState === 'live';
5885
+ const layers = isTrackLive
5886
+ ? computeVideoLayers(track, publishOption)
5887
+ : this.transceiverCache.getLayers(publishOption);
5888
+ this.transceiverCache.setLayers(publishOption, layers);
5889
+ const isAudioTrack = isAudioTrackType(publishOption.trackType);
5890
+ const isStereo = isAudioTrack && track.getSettings().channelCount === 2;
5891
+ const transceiverIndex = this.transceiverCache.indexOf(transceiver);
5892
+ const audioSettings = this.state.settings?.audio;
5893
+ return {
5894
+ trackId: track.id,
5895
+ layers: toVideoLayers(layers),
5896
+ trackType: publishOption.trackType,
5897
+ mid: extractMid(transceiver, transceiverIndex, sdp),
5898
+ stereo: isStereo,
5899
+ dtx: isAudioTrack && !!audioSettings?.opus_dtx_enabled,
5900
+ red: isAudioTrack && !!audioSettings?.redundant_coding_enabled,
5901
+ muted: !isTrackLive,
5902
+ codec: publishOption.codec,
5903
+ publishOptionId: publishOption.id,
5904
+ };
6425
5905
  };
5906
+ this.publishOptions = publishOptions;
5907
+ this.pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5908
+ this.on('iceRestart', (iceRestart) => {
5909
+ if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
5910
+ return;
5911
+ this.restartIce().catch((err) => {
5912
+ this.logger('warn', `ICERestart failed`, err);
5913
+ this.onUnrecoverableError?.();
5914
+ });
5915
+ });
5916
+ this.on('changePublishQuality', async (event) => {
5917
+ for (const videoSender of event.videoSenders) {
5918
+ await this.changePublishQuality(videoSender);
5919
+ }
5920
+ });
5921
+ this.on('changePublishOptions', (event) => {
5922
+ this.publishOptions = event.publishOptions;
5923
+ return this.syncPublishOptions();
5924
+ });
5925
+ }
5926
+ /**
5927
+ * Detaches the event handlers from the `RTCPeerConnection`.
5928
+ * This is useful when we want to replace the `RTCPeerConnection`
5929
+ * instance with a new one (in case of migration).
5930
+ */
5931
+ detachEventHandlers() {
5932
+ super.detachEventHandlers();
5933
+ this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5934
+ }
5935
+ }
5936
+
5937
+ /**
5938
+ * A wrapper around the `RTCPeerConnection` that handles the incoming
5939
+ * media streams from the SFU.
5940
+ *
5941
+ * @internal
5942
+ */
5943
+ class Subscriber extends BasePeerConnection {
5944
+ /**
5945
+ * Constructs a new `Subscriber` instance.
5946
+ */
5947
+ constructor(opts) {
5948
+ super(PeerType.SUBSCRIBER, opts);
6426
5949
  /**
6427
5950
  * Restarts the ICE connection and renegotiates with the SFU.
6428
5951
  */
@@ -6485,7 +6008,15 @@ class Subscriber {
6485
6008
  this.logger('error', `Unknown track type: ${rawTrackType}`);
6486
6009
  return;
6487
6010
  }
6011
+ // get the previous stream to dispose it later
6012
+ // usually this happens during migration, when the stream is replaced
6013
+ // with a new one but the old one is still in the state
6488
6014
  const previousStream = participantToUpdate[streamKindProp];
6015
+ // replace the previous stream with the new one, prevents flickering
6016
+ this.state.updateParticipant(participantToUpdate.sessionId, {
6017
+ [streamKindProp]: primaryStream,
6018
+ });
6019
+ // now, dispose the previous stream if it exists
6489
6020
  if (previousStream) {
6490
6021
  this.logger('info', `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
6491
6022
  previousStream.getTracks().forEach((t) => {
@@ -6493,24 +6024,6 @@ class Subscriber {
6493
6024
  previousStream.removeTrack(t);
6494
6025
  });
6495
6026
  }
6496
- this.state.updateParticipant(participantToUpdate.sessionId, {
6497
- [streamKindProp]: primaryStream,
6498
- });
6499
- };
6500
- this.onIceCandidate = (e) => {
6501
- const { candidate } = e;
6502
- if (!candidate) {
6503
- this.logger('debug', 'null ice candidate');
6504
- return;
6505
- }
6506
- this.sfuClient
6507
- .iceTrickle({
6508
- iceCandidate: getIceCandidate(candidate),
6509
- peerType: PeerType.SUBSCRIBER,
6510
- })
6511
- .catch((err) => {
6512
- this.logger('warn', `ICETrickle failed`, err);
6513
- });
6514
6027
  };
6515
6028
  this.negotiate = async (subscriberOffer) => {
6516
6029
  this.logger('info', `Received subscriberOffer`, subscriberOffer);
@@ -6518,15 +6031,7 @@ class Subscriber {
6518
6031
  type: 'offer',
6519
6032
  sdp: subscriberOffer.sdp,
6520
6033
  });
6521
- this.sfuClient.iceTrickleBuffer.subscriberCandidates.subscribe(async (candidate) => {
6522
- try {
6523
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6524
- await this.pc.addIceCandidate(iceCandidate);
6525
- }
6526
- catch (e) {
6527
- this.logger('warn', `ICE candidate error`, [e, candidate]);
6528
- }
6529
- });
6034
+ this.addTrickledIceCandidates();
6530
6035
  const answer = await this.pc.createAnswer();
6531
6036
  await this.pc.setLocalDescription(answer);
6532
6037
  await this.sfuClient.sendAnswer({
@@ -6535,56 +6040,21 @@ class Subscriber {
6535
6040
  });
6536
6041
  this.isIceRestarting = false;
6537
6042
  };
6538
- this.onIceConnectionStateChange = () => {
6539
- const state = this.pc.iceConnectionState;
6540
- this.logger('debug', `ICE connection state changed`, state);
6541
- if (this.state.callingState === exports.CallingState.RECONNECTING)
6542
- return;
6543
- // do nothing when ICE is restarting
6544
- if (this.isIceRestarting)
6545
- return;
6546
- if (state === 'failed' || state === 'disconnected') {
6547
- this.logger('debug', `Attempting to restart ICE`);
6548
- this.restartIce().catch((e) => {
6549
- this.logger('error', `ICE restart failed`, e);
6550
- this.onUnrecoverableError?.();
6551
- });
6552
- }
6553
- };
6554
- this.onIceGatheringStateChange = () => {
6555
- this.logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
6556
- };
6557
- this.onIceCandidateError = (e) => {
6558
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6559
- `${e.errorCode}: ${e.errorText}`;
6560
- const iceState = this.pc.iceConnectionState;
6561
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6562
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6563
- };
6564
- this.logger = getLogger(['Subscriber', logTag]);
6565
- this.sfuClient = sfuClient;
6566
- this.state = state;
6567
- this.onUnrecoverableError = onUnrecoverableError;
6568
- this.pc = this.createPeerConnection(connectionConfig);
6569
- const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
6570
- this.unregisterOnSubscriberOffer = dispatcher.on('subscriberOffer', (subscriberOffer) => {
6571
- withoutConcurrency(subscriberOfferConcurrencyTag, () => {
6572
- return this.negotiate(subscriberOffer);
6573
- }).catch((err) => {
6043
+ this.pc.addEventListener('track', this.handleOnTrack);
6044
+ this.on('subscriberOffer', async (subscriberOffer) => {
6045
+ return this.negotiate(subscriberOffer).catch((err) => {
6574
6046
  this.logger('error', `Negotiation failed.`, err);
6575
6047
  });
6576
6048
  });
6577
- const iceRestartConcurrencyTag = Symbol('iceRestart');
6578
- this.unregisterOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6579
- withoutConcurrency(iceRestartConcurrencyTag, async () => {
6580
- if (iceRestart.peerType !== PeerType.SUBSCRIBER)
6581
- return;
6582
- await this.restartIce();
6583
- }).catch((err) => {
6584
- this.logger('error', `ICERestart failed`, err);
6585
- this.onUnrecoverableError?.();
6586
- });
6587
- });
6049
+ }
6050
+ /**
6051
+ * Detaches the event handlers from the `RTCPeerConnection`.
6052
+ * This is useful when we want to replace the `RTCPeerConnection`
6053
+ * instance with a new one (in case of migration).
6054
+ */
6055
+ detachEventHandlers() {
6056
+ super.detachEventHandlers();
6057
+ this.pc.removeEventListener('track', this.handleOnTrack);
6588
6058
  }
6589
6059
  }
6590
6060
 
@@ -6616,6 +6086,16 @@ const createWebSocketSignalChannel = (opts) => {
6616
6086
  return ws;
6617
6087
  };
6618
6088
 
6089
+ const toRtcConfiguration = (config) => {
6090
+ return {
6091
+ iceServers: config.map((ice) => ({
6092
+ urls: ice.urls,
6093
+ username: ice.username,
6094
+ credential: ice.password,
6095
+ })),
6096
+ };
6097
+ };
6098
+
6619
6099
  /**
6620
6100
  * Saving a long-lived reference to a promise that can reject can be unsafe,
6621
6101
  * since rejecting the promise causes an unhandled rejection error (even if the
@@ -6900,6 +6380,7 @@ class StreamSfuClient {
6900
6380
  clearTimeout(this.migrateAwayTimeout);
6901
6381
  this.abortController.abort();
6902
6382
  this.migrationTask?.resolve();
6383
+ this.iceTrickleBuffer.dispose();
6903
6384
  };
6904
6385
  this.leaveAndClose = async (reason) => {
6905
6386
  await this.joinTask;
@@ -6932,13 +6413,9 @@ class StreamSfuClient {
6932
6413
  await this.joinTask;
6933
6414
  return retryable(() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6934
6415
  };
6935
- this.updateMuteState = async (trackType, muted) => {
6936
- await this.joinTask;
6937
- return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
6938
- };
6939
- this.updateMuteStates = async (data) => {
6416
+ this.updateMuteStates = async (muteStates) => {
6940
6417
  await this.joinTask;
6941
- return retryable(() => this.rpc.updateMuteStates({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6418
+ return retryable(() => this.rpc.updateMuteStates({ muteStates, sessionId: this.sessionId }), this.abortController.signal);
6942
6419
  };
6943
6420
  this.sendStats = async (stats) => {
6944
6421
  await this.joinTask;
@@ -7118,16 +6595,6 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
7118
6595
  */
7119
6596
  StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
7120
6597
 
7121
- const toRtcConfiguration = (config) => {
7122
- return {
7123
- iceServers: config.map((ice) => ({
7124
- urls: ice.urls,
7125
- username: ice.username,
7126
- credential: ice.password,
7127
- })),
7128
- };
7129
- };
7130
-
7131
6598
  /**
7132
6599
  * Event handler that watched the delivery of `call.accepted`.
7133
6600
  * Once the event is received, the call is joined.
@@ -7346,6 +6813,21 @@ const handleRemoteSoftMute = (call) => {
7346
6813
  });
7347
6814
  };
7348
6815
 
6816
+ /**
6817
+ * Adds unique values to an array.
6818
+ *
6819
+ * @param arr the array to add to.
6820
+ * @param values the values to add.
6821
+ */
6822
+ const pushToIfMissing = (arr, ...values) => {
6823
+ for (const v of values) {
6824
+ if (!arr.includes(v)) {
6825
+ arr.push(v);
6826
+ }
6827
+ }
6828
+ return arr;
6829
+ };
6830
+
7349
6831
  /**
7350
6832
  * An event responder which handles the `participantJoined` event.
7351
6833
  */
@@ -7411,7 +6893,7 @@ const watchTrackPublished = (state) => {
7411
6893
  }
7412
6894
  else {
7413
6895
  state.updateParticipant(sessionId, (p) => ({
7414
- publishedTracks: [...p.publishedTracks, type].filter(unique),
6896
+ publishedTracks: pushToIfMissing([...p.publishedTracks], type),
7415
6897
  }));
7416
6898
  }
7417
6899
  };
@@ -7436,7 +6918,6 @@ const watchTrackUnpublished = (state) => {
7436
6918
  }
7437
6919
  };
7438
6920
  };
7439
- const unique = (v, i, arr) => arr.indexOf(v) === i;
7440
6921
  /**
7441
6922
  * Reconciles orphaned tracks (if any) for the given participant.
7442
6923
  *
@@ -7586,6 +7067,38 @@ const getSdkVersion = (sdk) => {
7586
7067
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7587
7068
  };
7588
7069
 
7070
+ /**
7071
+ * Checks whether the current browser is Safari.
7072
+ */
7073
+ const isSafari = () => {
7074
+ if (typeof navigator === 'undefined')
7075
+ return false;
7076
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
7077
+ };
7078
+ /**
7079
+ * Checks whether the current browser is Firefox.
7080
+ */
7081
+ const isFirefox = () => {
7082
+ if (typeof navigator === 'undefined')
7083
+ return false;
7084
+ return navigator.userAgent?.includes('Firefox');
7085
+ };
7086
+ /**
7087
+ * Checks whether the current browser is Google Chrome.
7088
+ */
7089
+ const isChrome = () => {
7090
+ if (typeof navigator === 'undefined')
7091
+ return false;
7092
+ return navigator.userAgent?.includes('Chrome');
7093
+ };
7094
+
7095
+ var browsers = /*#__PURE__*/Object.freeze({
7096
+ __proto__: null,
7097
+ isChrome: isChrome,
7098
+ isFirefox: isFirefox,
7099
+ isSafari: isSafari
7100
+ });
7101
+
7589
7102
  /**
7590
7103
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
7591
7104
  */
@@ -7602,12 +7115,12 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7602
7115
  return undefined;
7603
7116
  }
7604
7117
  };
7605
- const getStatsForStream = async (kind, mediaStream) => {
7118
+ const getStatsForStream = async (kind, tracks) => {
7606
7119
  const pc = kind === 'subscriber' ? subscriber : publisher;
7607
7120
  if (!pc)
7608
7121
  return [];
7609
7122
  const statsForStream = [];
7610
- for (let track of mediaStream.getTracks()) {
7123
+ for (const track of tracks) {
7611
7124
  const report = await pc.getStats(track);
7612
7125
  const stats = transform(report, {
7613
7126
  // @ts-ignore
@@ -7632,26 +7145,24 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7632
7145
  */
7633
7146
  const run = async () => {
7634
7147
  const participantStats = {};
7635
- const sessionIds = new Set(sessionIdsToTrack);
7636
- if (sessionIds.size > 0) {
7637
- for (let participant of state.participants) {
7148
+ if (sessionIdsToTrack.size > 0) {
7149
+ const sessionIds = new Set(sessionIdsToTrack);
7150
+ for (const participant of state.participants) {
7638
7151
  if (!sessionIds.has(participant.sessionId))
7639
7152
  continue;
7640
- const kind = participant.isLocalParticipant
7641
- ? 'publisher'
7642
- : 'subscriber';
7153
+ const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant;
7154
+ const kind = isLocalParticipant ? 'publisher' : 'subscriber';
7643
7155
  try {
7644
- const mergedStream = new MediaStream([
7645
- ...(participant.videoStream?.getVideoTracks() || []),
7646
- ...(participant.audioStream?.getAudioTracks() || []),
7647
- ]);
7648
- participantStats[participant.sessionId] = await getStatsForStream(kind, mergedStream);
7649
- mergedStream.getTracks().forEach((t) => {
7650
- mergedStream.removeTrack(t);
7651
- });
7156
+ const tracks = isLocalParticipant
7157
+ ? publisher?.getPublishedTracks() || []
7158
+ : [
7159
+ ...(videoStream?.getVideoTracks() || []),
7160
+ ...(audioStream?.getAudioTracks() || []),
7161
+ ];
7162
+ participantStats[sessionId] = await getStatsForStream(kind, tracks);
7652
7163
  }
7653
7164
  catch (e) {
7654
- logger('error', `Failed to collect stats for ${kind} of ${participant.userId}`, e);
7165
+ logger('warn', `Failed to collect ${kind} stats for ${userId}`, e);
7655
7166
  }
7656
7167
  }
7657
7168
  }
@@ -7661,6 +7172,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7661
7172
  .then((report) => transform(report, {
7662
7173
  kind: 'subscriber',
7663
7174
  trackKind: 'video',
7175
+ publisher,
7664
7176
  }))
7665
7177
  .then(aggregate),
7666
7178
  publisher
@@ -7669,6 +7181,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7669
7181
  .then((report) => transform(report, {
7670
7182
  kind: 'publisher',
7671
7183
  trackKind: 'video',
7184
+ publisher,
7672
7185
  }))
7673
7186
  .then(aggregate)
7674
7187
  : getEmptyStats(),
@@ -7717,7 +7230,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7717
7230
  * @param opts the transform options.
7718
7231
  */
7719
7232
  const transform = (report, opts) => {
7720
- const { trackKind, kind } = opts;
7233
+ const { trackKind, kind, publisher } = opts;
7721
7234
  const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
7722
7235
  const stats = flatten(report);
7723
7236
  const streams = stats
@@ -7733,6 +7246,16 @@ const transform = (report, opts) => {
7733
7246
  s.id === transport.selectedCandidatePairId);
7734
7247
  roundTripTime = candidatePair?.currentRoundTripTime;
7735
7248
  }
7249
+ let trackType;
7250
+ if (kind === 'publisher' && publisher) {
7251
+ const firefox = isFirefox();
7252
+ const mediaSource = stats.find((s) => s.type === 'media-source' &&
7253
+ // Firefox doesn't have mediaSourceId, so we need to guess the media source
7254
+ (firefox ? true : s.id === rtcStreamStats.mediaSourceId));
7255
+ if (mediaSource) {
7256
+ trackType = publisher.getTrackType(mediaSource.trackIdentifier);
7257
+ }
7258
+ }
7736
7259
  return {
7737
7260
  bytesSent: rtcStreamStats.bytesSent,
7738
7261
  bytesReceived: rtcStreamStats.bytesReceived,
@@ -7743,10 +7266,12 @@ const transform = (report, opts) => {
7743
7266
  framesPerSecond: rtcStreamStats.framesPerSecond,
7744
7267
  jitter: rtcStreamStats.jitter,
7745
7268
  kind: rtcStreamStats.kind,
7269
+ mediaSourceId: rtcStreamStats.mediaSourceId,
7746
7270
  // @ts-ignore: available in Chrome only, TS doesn't recognize this
7747
7271
  qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
7748
7272
  rid: rtcStreamStats.rid,
7749
7273
  ssrc: rtcStreamStats.ssrc,
7274
+ trackType,
7750
7275
  };
7751
7276
  });
7752
7277
  return {
@@ -7767,6 +7292,7 @@ const getEmptyStats = (stats) => {
7767
7292
  highestFrameHeight: 0,
7768
7293
  highestFramesPerSecond: 0,
7769
7294
  codec: '',
7295
+ codecPerTrackType: {},
7770
7296
  timestamp: Date.now(),
7771
7297
  };
7772
7298
  };
@@ -7802,6 +7328,12 @@ const aggregate = (stats) => {
7802
7328
  report.averageRoundTripTimeInMs = Math.round((report.averageRoundTripTimeInMs / streams.length) * 1000);
7803
7329
  // we take the first codec we find, as it should be the same for all streams
7804
7330
  report.codec = streams[0].codec || '';
7331
+ report.codecPerTrackType = streams.reduce((acc, stream) => {
7332
+ if (stream.trackType) {
7333
+ acc[stream.trackType] = stream.codec || '';
7334
+ }
7335
+ return acc;
7336
+ }, {});
7805
7337
  }
7806
7338
  const qualityLimitationReason = [
7807
7339
  qualityLimitationReasons.has('cpu') && 'cpu',
@@ -7813,7 +7345,135 @@ const aggregate = (stats) => {
7813
7345
  if (qualityLimitationReason) {
7814
7346
  report.qualityLimitationReasons = qualityLimitationReason;
7815
7347
  }
7816
- return report;
7348
+ return report;
7349
+ };
7350
+
7351
+ const version = "1.15.1";
7352
+ const [major, minor, patch] = version.split('.');
7353
+ let sdkInfo = {
7354
+ type: SdkType.PLAIN_JAVASCRIPT,
7355
+ major,
7356
+ minor,
7357
+ patch,
7358
+ };
7359
+ let osInfo;
7360
+ let deviceInfo;
7361
+ let webRtcInfo;
7362
+ let deviceState = { oneofKind: undefined };
7363
+ const setSdkInfo = (info) => {
7364
+ sdkInfo = info;
7365
+ };
7366
+ const getSdkInfo = () => {
7367
+ return sdkInfo;
7368
+ };
7369
+ const setOSInfo = (info) => {
7370
+ osInfo = info;
7371
+ };
7372
+ const getOSInfo = () => {
7373
+ return osInfo;
7374
+ };
7375
+ const setDeviceInfo = (info) => {
7376
+ deviceInfo = info;
7377
+ };
7378
+ const getDeviceInfo = () => {
7379
+ return deviceInfo;
7380
+ };
7381
+ const getWebRTCInfo = () => {
7382
+ return webRtcInfo;
7383
+ };
7384
+ const setWebRTCInfo = (info) => {
7385
+ webRtcInfo = info;
7386
+ };
7387
+ const setThermalState = (state) => {
7388
+ if (!osInfo) {
7389
+ deviceState = { oneofKind: undefined };
7390
+ return;
7391
+ }
7392
+ if (osInfo.name === 'android') {
7393
+ const thermalState = AndroidThermalState[state] ||
7394
+ AndroidThermalState.UNSPECIFIED;
7395
+ deviceState = {
7396
+ oneofKind: 'android',
7397
+ android: {
7398
+ thermalState,
7399
+ isPowerSaverMode: deviceState?.oneofKind === 'android' &&
7400
+ deviceState.android.isPowerSaverMode,
7401
+ },
7402
+ };
7403
+ }
7404
+ if (osInfo.name.toLowerCase() === 'ios') {
7405
+ const thermalState = AppleThermalState[state] ||
7406
+ AppleThermalState.UNSPECIFIED;
7407
+ deviceState = {
7408
+ oneofKind: 'apple',
7409
+ apple: {
7410
+ thermalState,
7411
+ isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
7412
+ deviceState.apple.isLowPowerModeEnabled,
7413
+ },
7414
+ };
7415
+ }
7416
+ };
7417
+ const setPowerState = (powerMode) => {
7418
+ if (!osInfo) {
7419
+ deviceState = { oneofKind: undefined };
7420
+ return;
7421
+ }
7422
+ if (osInfo.name === 'android') {
7423
+ deviceState = {
7424
+ oneofKind: 'android',
7425
+ android: {
7426
+ thermalState: deviceState?.oneofKind === 'android'
7427
+ ? deviceState.android.thermalState
7428
+ : AndroidThermalState.UNSPECIFIED,
7429
+ isPowerSaverMode: powerMode,
7430
+ },
7431
+ };
7432
+ }
7433
+ if (osInfo.name.toLowerCase() === 'ios') {
7434
+ deviceState = {
7435
+ oneofKind: 'apple',
7436
+ apple: {
7437
+ thermalState: deviceState?.oneofKind === 'apple'
7438
+ ? deviceState.apple.thermalState
7439
+ : AppleThermalState.UNSPECIFIED,
7440
+ isLowPowerModeEnabled: powerMode,
7441
+ },
7442
+ };
7443
+ }
7444
+ };
7445
+ const getDeviceState = () => {
7446
+ return deviceState;
7447
+ };
7448
+ const getClientDetails = () => {
7449
+ if (isReactNative()) {
7450
+ // Since RN doesn't support web, sharing browser info is not required
7451
+ return {
7452
+ sdk: getSdkInfo(),
7453
+ os: getOSInfo(),
7454
+ device: getDeviceInfo(),
7455
+ };
7456
+ }
7457
+ const userAgent = new uaParserJs.UAParser(navigator.userAgent);
7458
+ const { browser, os, device, cpu } = userAgent.getResult();
7459
+ return {
7460
+ sdk: getSdkInfo(),
7461
+ browser: {
7462
+ name: browser.name || navigator.userAgent,
7463
+ version: browser.version || '',
7464
+ },
7465
+ os: {
7466
+ name: os.name || '',
7467
+ version: os.version || '',
7468
+ architecture: cpu.architecture || '',
7469
+ },
7470
+ device: {
7471
+ name: [device.vendor, device.model, device.type]
7472
+ .filter(Boolean)
7473
+ .join(' '),
7474
+ version: '',
7475
+ },
7476
+ };
7817
7477
  };
7818
7478
 
7819
7479
  class SfuStatsReporter {
@@ -7849,8 +7509,28 @@ class SfuStatsReporter {
7849
7509
  });
7850
7510
  });
7851
7511
  };
7852
- this.sendTelemetryData = async (telemetryData) => {
7853
- return this.run(telemetryData);
7512
+ this.sendConnectionTime = (connectionTimeSeconds) => {
7513
+ this.sendTelemetryData({
7514
+ data: {
7515
+ oneofKind: 'connectionTimeSeconds',
7516
+ connectionTimeSeconds,
7517
+ },
7518
+ });
7519
+ };
7520
+ this.sendReconnectionTime = (strategy, timeSeconds) => {
7521
+ this.sendTelemetryData({
7522
+ data: {
7523
+ oneofKind: 'reconnection',
7524
+ reconnection: { strategy, timeSeconds },
7525
+ },
7526
+ });
7527
+ };
7528
+ this.sendTelemetryData = (telemetryData) => {
7529
+ // intentionally not awaiting the promise here
7530
+ // to avoid impeding with the ongoing actions.
7531
+ this.run(telemetryData).catch((err) => {
7532
+ this.logger('warn', 'Failed to send telemetry data', err);
7533
+ });
7854
7534
  };
7855
7535
  this.run = async (telemetryData) => {
7856
7536
  const [subscriberStats, publisherStats] = await Promise.all([
@@ -8418,6 +8098,25 @@ class PermissionsContext {
8418
8098
  this.hasPermission = (permission) => {
8419
8099
  return this.permissions.includes(permission);
8420
8100
  };
8101
+ /**
8102
+ * Helper method that checks whether the current user has the permission
8103
+ * to publish the given track type.
8104
+ */
8105
+ this.canPublish = (trackType) => {
8106
+ switch (trackType) {
8107
+ case TrackType.AUDIO:
8108
+ return this.hasPermission(OwnCapability.SEND_AUDIO);
8109
+ case TrackType.VIDEO:
8110
+ return this.hasPermission(OwnCapability.SEND_VIDEO);
8111
+ case TrackType.SCREEN_SHARE:
8112
+ case TrackType.SCREEN_SHARE_AUDIO:
8113
+ return this.hasPermission(OwnCapability.SCREENSHARE);
8114
+ case TrackType.UNSPECIFIED:
8115
+ return false;
8116
+ default:
8117
+ ensureExhausted(trackType, 'Unknown track type');
8118
+ }
8119
+ };
8421
8120
  /**
8422
8121
  * Checks if the current user can request a specific permission
8423
8122
  * within the call.
@@ -8874,6 +8573,14 @@ const disposeOfMediaStream = (stream) => {
8874
8573
  }
8875
8574
  };
8876
8575
 
8576
+ /**
8577
+ * Checks if the current platform is a mobile device.
8578
+ *
8579
+ * See:
8580
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
8581
+ */
8582
+ const isMobile = () => /Mobi/i.test(navigator.userAgent);
8583
+
8877
8584
  class InputMediaDeviceManager {
8878
8585
  constructor(call, state, trackType) {
8879
8586
  this.call = call;
@@ -9056,36 +8763,42 @@ class InputMediaDeviceManager {
9056
8763
  }
9057
8764
  });
9058
8765
  }
8766
+ publishStream(stream) {
8767
+ return this.call.publish(stream, this.trackType);
8768
+ }
8769
+ stopPublishStream() {
8770
+ return this.call.stopPublish(this.trackType);
8771
+ }
9059
8772
  getTracks() {
9060
8773
  return this.state.mediaStream?.getTracks() ?? [];
9061
8774
  }
9062
8775
  async muteStream(stopTracks = true) {
9063
- if (!this.state.mediaStream)
8776
+ const mediaStream = this.state.mediaStream;
8777
+ if (!mediaStream)
9064
8778
  return;
9065
8779
  this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
9066
8780
  if (this.call.state.callingState === exports.CallingState.JOINED) {
9067
- await this.stopPublishStream(stopTracks);
8781
+ await this.stopPublishStream();
9068
8782
  }
9069
8783
  this.muteLocalStream(stopTracks);
9070
8784
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
9071
8785
  if (allEnded) {
9072
- if (this.state.mediaStream &&
9073
- // @ts-expect-error release() is present in react-native-webrtc
9074
- typeof this.state.mediaStream.release === 'function') {
8786
+ // @ts-expect-error release() is present in react-native-webrtc
8787
+ if (typeof mediaStream.release === 'function') {
9075
8788
  // @ts-expect-error called to dispose the stream in RN
9076
- this.state.mediaStream.release();
8789
+ mediaStream.release();
9077
8790
  }
9078
8791
  this.state.setMediaStream(undefined, undefined);
9079
8792
  this.filters.forEach((entry) => entry.stop?.());
9080
8793
  }
9081
8794
  }
9082
- muteTracks() {
8795
+ disableTracks() {
9083
8796
  this.getTracks().forEach((track) => {
9084
8797
  if (track.enabled)
9085
8798
  track.enabled = false;
9086
8799
  });
9087
8800
  }
9088
- unmuteTracks() {
8801
+ enableTracks() {
9089
8802
  this.getTracks().forEach((track) => {
9090
8803
  if (!track.enabled)
9091
8804
  track.enabled = true;
@@ -9105,7 +8818,7 @@ class InputMediaDeviceManager {
9105
8818
  this.stopTracks();
9106
8819
  }
9107
8820
  else {
9108
- this.muteTracks();
8821
+ this.disableTracks();
9109
8822
  }
9110
8823
  }
9111
8824
  async unmuteStream() {
@@ -9115,7 +8828,7 @@ class InputMediaDeviceManager {
9115
8828
  if (this.state.mediaStream &&
9116
8829
  this.getTracks().every((t) => t.readyState === 'live')) {
9117
8830
  stream = this.state.mediaStream;
9118
- this.unmuteTracks();
8831
+ this.enableTracks();
9119
8832
  }
9120
8833
  else {
9121
8834
  const defaultConstraints = this.state.defaultConstraints;
@@ -9209,9 +8922,24 @@ class InputMediaDeviceManager {
9209
8922
  await this.disable();
9210
8923
  }
9211
8924
  };
9212
- this.getTracks().forEach((track) => {
8925
+ const createTrackMuteHandler = (muted) => () => {
8926
+ if (!isMobile() || this.trackType !== TrackType.VIDEO)
8927
+ return;
8928
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
8929
+ this.logger('warn', 'Error while notifying track mute state', err);
8930
+ });
8931
+ };
8932
+ stream.getTracks().forEach((track) => {
8933
+ const muteHandler = createTrackMuteHandler(true);
8934
+ const unmuteHandler = createTrackMuteHandler(false);
8935
+ track.addEventListener('mute', muteHandler);
8936
+ track.addEventListener('unmute', unmuteHandler);
9213
8937
  track.addEventListener('ended', handleTrackEnded);
9214
- this.subscriptions.push(() => track.removeEventListener('ended', handleTrackEnded));
8938
+ this.subscriptions.push(() => {
8939
+ track.removeEventListener('mute', muteHandler);
8940
+ track.removeEventListener('unmute', unmuteHandler);
8941
+ track.removeEventListener('ended', handleTrackEnded);
8942
+ });
9215
8943
  });
9216
8944
  }
9217
8945
  }
@@ -9235,8 +8963,8 @@ class InputMediaDeviceManager {
9235
8963
  await this.statusChangeSettled();
9236
8964
  let isDeviceDisconnected = false;
9237
8965
  let isDeviceReplaced = false;
9238
- const currentDevice = this.findDeviceInList(currentDevices, deviceId);
9239
- const prevDevice = this.findDeviceInList(prevDevices, deviceId);
8966
+ const currentDevice = this.findDevice(currentDevices, deviceId);
8967
+ const prevDevice = this.findDevice(prevDevices, deviceId);
9240
8968
  if (!currentDevice && prevDevice) {
9241
8969
  isDeviceDisconnected = true;
9242
8970
  }
@@ -9266,8 +8994,9 @@ class InputMediaDeviceManager {
9266
8994
  }
9267
8995
  }));
9268
8996
  }
9269
- findDeviceInList(devices, deviceId) {
9270
- return devices.find((d) => d.deviceId === deviceId && d.kind === this.mediaDeviceKind);
8997
+ findDevice(devices, deviceId) {
8998
+ const kind = this.mediaDeviceKind;
8999
+ return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
9271
9000
  }
9272
9001
  }
9273
9002
 
@@ -9453,14 +9182,6 @@ class CameraManagerState extends InputMediaDeviceManagerState {
9453
9182
  }
9454
9183
  }
9455
9184
 
9456
- /**
9457
- * Checks if the current platform is a mobile device.
9458
- *
9459
- * See:
9460
- * https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
9461
- */
9462
- const isMobile = () => /Mobi/i.test(navigator.userAgent);
9463
-
9464
9185
  class CameraManager extends InputMediaDeviceManager {
9465
9186
  /**
9466
9187
  * Constructs a new CameraManager.
@@ -9531,14 +9252,35 @@ class CameraManager extends InputMediaDeviceManager {
9531
9252
  }
9532
9253
  }
9533
9254
  /**
9534
- * Sets the preferred codec for encoding the video.
9255
+ * Applies the video settings to the camera.
9535
9256
  *
9536
- * @internal internal use only, not part of the public API.
9537
- * @deprecated use {@link call.updatePublishOptions} instead.
9538
- * @param codec the codec to use for encoding the video.
9257
+ * @param settings the video settings to apply.
9258
+ * @param publish whether to publish the stream after applying the settings.
9539
9259
  */
9540
- setPreferredCodec(codec) {
9541
- this.call.updatePublishOptions({ preferredCodec: codec });
9260
+ async apply(settings, publish) {
9261
+ const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
9262
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9263
+ if (hasPublishedVideo || !hasPermission)
9264
+ return;
9265
+ // Wait for any in progress camera operation
9266
+ await this.statusChangeSettled();
9267
+ const { target_resolution, camera_facing, camera_default_on } = settings;
9268
+ await this.selectTargetResolution(target_resolution);
9269
+ // Set camera direction if it's not yet set
9270
+ if (!this.state.direction && !this.state.selectedDevice) {
9271
+ this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
9272
+ }
9273
+ if (!publish)
9274
+ return;
9275
+ const { mediaStream } = this.state;
9276
+ if (this.enabled && mediaStream) {
9277
+ // The camera is already enabled (e.g. lobby screen). Publish the stream
9278
+ await this.publishStream(mediaStream);
9279
+ }
9280
+ else if (this.state.status === undefined && camera_default_on) {
9281
+ // Start camera if backend config specifies, and there is no local setting
9282
+ await this.enable();
9283
+ }
9542
9284
  }
9543
9285
  getDevices() {
9544
9286
  return getVideoDevices();
@@ -9556,12 +9298,6 @@ class CameraManager extends InputMediaDeviceManager {
9556
9298
  }
9557
9299
  return getVideoStream(constraints);
9558
9300
  }
9559
- publishStream(stream) {
9560
- return this.call.publishVideoStream(stream);
9561
- }
9562
- stopPublishStream(stopTracks) {
9563
- return this.call.stopPublish(TrackType.VIDEO, stopTracks);
9564
- }
9565
9301
  }
9566
9302
 
9567
9303
  class MicrophoneManagerState extends InputMediaDeviceManagerState {
@@ -9889,18 +9625,37 @@ class MicrophoneManager extends InputMediaDeviceManager {
9889
9625
  this.speakingWhileMutedNotificationEnabled = false;
9890
9626
  await this.stopSpeakingWhileMutedDetection();
9891
9627
  }
9628
+ /**
9629
+ * Applies the audio settings to the microphone.
9630
+ * @param settings the audio settings to apply.
9631
+ * @param publish whether to publish the stream after applying the settings.
9632
+ */
9633
+ async apply(settings, publish) {
9634
+ if (!publish)
9635
+ return;
9636
+ const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
9637
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9638
+ if (hasPublishedAudio || !hasPermission)
9639
+ return;
9640
+ // Wait for any in progress mic operation
9641
+ await this.statusChangeSettled();
9642
+ // Publish media stream that was set before we joined
9643
+ const { mediaStream } = this.state;
9644
+ if (this.enabled && mediaStream) {
9645
+ // The mic is already enabled (e.g. lobby screen). Publish the stream
9646
+ await this.publishStream(mediaStream);
9647
+ }
9648
+ else if (this.state.status === undefined && settings.mic_default_on) {
9649
+ // Start mic if backend config specifies, and there is no local setting
9650
+ await this.enable();
9651
+ }
9652
+ }
9892
9653
  getDevices() {
9893
9654
  return getAudioDevices();
9894
9655
  }
9895
9656
  getStream(constraints) {
9896
9657
  return getAudioStream(constraints);
9897
9658
  }
9898
- publishStream(stream) {
9899
- return this.call.publishAudioStream(stream);
9900
- }
9901
- stopPublishStream(stopTracks) {
9902
- return this.call.stopPublish(TrackType.AUDIO, stopTracks);
9903
- }
9904
9659
  async startSpeakingWhileMutedDetection(deviceId) {
9905
9660
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
9906
9661
  await this.stopSpeakingWhileMutedDetection();
@@ -10020,7 +9775,7 @@ class ScreenShareManager extends InputMediaDeviceManager {
10020
9775
  async disableScreenShareAudio() {
10021
9776
  this.state.setAudioEnabled(false);
10022
9777
  if (this.call.publisher?.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
10023
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, true);
9778
+ await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO);
10024
9779
  }
10025
9780
  }
10026
9781
  /**
@@ -10046,12 +9801,8 @@ class ScreenShareManager extends InputMediaDeviceManager {
10046
9801
  }
10047
9802
  return getScreenShareStream(constraints);
10048
9803
  }
10049
- publishStream(stream) {
10050
- return this.call.publishScreenShareStream(stream);
10051
- }
10052
- async stopPublishStream(stopTracks) {
10053
- await this.call.stopPublish(TrackType.SCREEN_SHARE, stopTracks);
10054
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, stopTracks);
9804
+ async stopPublishStream() {
9805
+ return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
10055
9806
  }
10056
9807
  /**
10057
9808
  * Overrides the default `select` method to throw an error.
@@ -10261,6 +10012,112 @@ class Call {
10261
10012
  */
10262
10013
  this.leaveCallHooks = new Set();
10263
10014
  this.streamClientEventHandlers = new Map();
10015
+ this.setup = async () => {
10016
+ await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
10017
+ if (this.initialized)
10018
+ return;
10019
+ this.leaveCallHooks.add(this.on('all', (event) => {
10020
+ // update state with the latest event data
10021
+ this.state.updateFromEvent(event);
10022
+ }));
10023
+ this.leaveCallHooks.add(this.on('changePublishOptions', (event) => {
10024
+ this.currentPublishOptions = event.publishOptions;
10025
+ }));
10026
+ this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
10027
+ this.registerEffects();
10028
+ this.registerReconnectHandlers();
10029
+ if (this.state.callingState === exports.CallingState.LEFT) {
10030
+ this.state.setCallingState(exports.CallingState.IDLE);
10031
+ }
10032
+ this.initialized = true;
10033
+ });
10034
+ };
10035
+ this.registerEffects = () => {
10036
+ this.leaveCallHooks.add(
10037
+ // handles updating the permissions context when the settings change.
10038
+ createSubscription(this.state.settings$, (settings) => {
10039
+ if (!settings)
10040
+ return;
10041
+ this.permissionsContext.setCallSettings(settings);
10042
+ }));
10043
+ this.leaveCallHooks.add(
10044
+ // handle the case when the user permissions are modified.
10045
+ createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
10046
+ this.leaveCallHooks.add(
10047
+ // handles the case when the user is blocked by the call owner.
10048
+ createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
10049
+ if (!blockedUserIds || blockedUserIds.length === 0)
10050
+ return;
10051
+ const currentUserId = this.currentUserId;
10052
+ if (currentUserId && blockedUserIds.includes(currentUserId)) {
10053
+ this.logger('info', 'Leaving call because of being blocked');
10054
+ await this.leave({ reason: 'user blocked' }).catch((err) => {
10055
+ this.logger('error', 'Error leaving call after being blocked', err);
10056
+ });
10057
+ }
10058
+ }));
10059
+ this.leaveCallHooks.add(
10060
+ // cancel auto-drop when call is
10061
+ createSubscription(this.state.session$, (session) => {
10062
+ if (!this.ringing)
10063
+ return;
10064
+ const receiverId = this.clientStore.connectedUser?.id;
10065
+ if (!receiverId)
10066
+ return;
10067
+ const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
10068
+ const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
10069
+ if (isAcceptedByMe || isRejectedByMe) {
10070
+ this.cancelAutoDrop();
10071
+ }
10072
+ }));
10073
+ this.leaveCallHooks.add(
10074
+ // "ringing" mode effects and event handlers
10075
+ createSubscription(this.ringingSubject, (isRinging) => {
10076
+ if (!isRinging)
10077
+ return;
10078
+ const callSession = this.state.session;
10079
+ const receiver_id = this.clientStore.connectedUser?.id;
10080
+ const ended_at = callSession?.ended_at;
10081
+ const created_by_id = this.state.createdBy?.id;
10082
+ const rejected_by = callSession?.rejected_by;
10083
+ const accepted_by = callSession?.accepted_by;
10084
+ let leaveCallIdle = false;
10085
+ if (ended_at) {
10086
+ // call was ended before it was accepted or rejected so we should leave it to idle
10087
+ leaveCallIdle = true;
10088
+ }
10089
+ else if (created_by_id && rejected_by) {
10090
+ if (rejected_by[created_by_id]) {
10091
+ // call was cancelled by the caller
10092
+ leaveCallIdle = true;
10093
+ }
10094
+ }
10095
+ else if (receiver_id && rejected_by) {
10096
+ if (rejected_by[receiver_id]) {
10097
+ // call was rejected by the receiver in some other device
10098
+ leaveCallIdle = true;
10099
+ }
10100
+ }
10101
+ else if (receiver_id && accepted_by) {
10102
+ if (accepted_by[receiver_id]) {
10103
+ // call was accepted by the receiver in some other device
10104
+ leaveCallIdle = true;
10105
+ }
10106
+ }
10107
+ if (leaveCallIdle) {
10108
+ if (this.state.callingState !== exports.CallingState.IDLE) {
10109
+ this.state.setCallingState(exports.CallingState.IDLE);
10110
+ }
10111
+ }
10112
+ else {
10113
+ if (this.state.callingState === exports.CallingState.IDLE) {
10114
+ this.state.setCallingState(exports.CallingState.RINGING);
10115
+ }
10116
+ this.scheduleAutoDrop();
10117
+ this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
10118
+ }
10119
+ }));
10120
+ };
10264
10121
  this.handleOwnCapabilitiesUpdated = async (ownCapabilities) => {
10265
10122
  // update the permission context.
10266
10123
  this.permissionsContext.setPermissions(ownCapabilities);
@@ -10373,9 +10230,9 @@ class Call {
10373
10230
  this.statsReporter = undefined;
10374
10231
  this.sfuStatsReporter?.stop();
10375
10232
  this.sfuStatsReporter = undefined;
10376
- this.subscriber?.close();
10233
+ this.subscriber?.dispose();
10377
10234
  this.subscriber = undefined;
10378
- this.publisher?.close({ stopTracks: true });
10235
+ this.publisher?.dispose();
10379
10236
  this.publisher = undefined;
10380
10237
  await this.sfuClient?.leaveAndClose(reason);
10381
10238
  this.sfuClient = undefined;
@@ -10413,7 +10270,8 @@ class Call {
10413
10270
  // call.ring event excludes the call creator in the members list
10414
10271
  // as the creator does not get the ring event
10415
10272
  // so update the member list accordingly
10416
- const creator = this.state.members.find((m) => m.user.id === event.call.created_by.id);
10273
+ const { created_by, settings } = event.call;
10274
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
10417
10275
  if (!creator) {
10418
10276
  this.state.setMembers(event.members);
10419
10277
  }
@@ -10428,7 +10286,7 @@ class Call {
10428
10286
  // const calls = useCalls().filter((c) => c.ringing);
10429
10287
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
10430
10288
  this.clientStore.setCalls([this, ...calls]);
10431
- await this.applyDeviceConfig(false);
10289
+ await this.applyDeviceConfig(settings, false);
10432
10290
  };
10433
10291
  /**
10434
10292
  * Loads the information about the call.
@@ -10451,7 +10309,7 @@ class Call {
10451
10309
  this.watching = true;
10452
10310
  this.clientStore.registerCall(this);
10453
10311
  }
10454
- await this.applyDeviceConfig(false);
10312
+ await this.applyDeviceConfig(response.call.settings, false);
10455
10313
  return response;
10456
10314
  };
10457
10315
  /**
@@ -10473,7 +10331,7 @@ class Call {
10473
10331
  this.watching = true;
10474
10332
  this.clientStore.registerCall(this);
10475
10333
  }
10476
- await this.applyDeviceConfig(false);
10334
+ await this.applyDeviceConfig(response.call.settings, false);
10477
10335
  return response;
10478
10336
  };
10479
10337
  /**
@@ -10575,19 +10433,32 @@ class Call {
10575
10433
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
10576
10434
  if (previousSfuClient !== sfuClient) {
10577
10435
  // prepare a generic SDP and send it to the SFU.
10578
- // this is a throw-away SDP that the SFU will use to determine
10436
+ // these are throw-away SDPs that the SFU will use to determine
10579
10437
  // the capabilities of the client (codec support, etc.)
10580
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
10581
- const reconnectDetails = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
10438
+ const [subscriberSdp, publisherSdp] = await Promise.all([
10439
+ getGenericSdp('recvonly'),
10440
+ getGenericSdp('sendonly'),
10441
+ ]);
10442
+ const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
10443
+ const reconnectDetails = isReconnecting
10582
10444
  ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
10583
10445
  : undefined;
10584
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
10585
- subscriberSdp: receivingCapabilitiesSdp,
10586
- publisherSdp: '',
10446
+ const preferredPublishOptions = !isReconnecting
10447
+ ? this.getPreferredPublishOptions()
10448
+ : this.currentPublishOptions || [];
10449
+ const preferredSubscribeOptions = !isReconnecting
10450
+ ? this.getPreferredSubscribeOptions()
10451
+ : [];
10452
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
10453
+ subscriberSdp,
10454
+ publisherSdp,
10587
10455
  clientDetails,
10588
10456
  fastReconnect: performingFastReconnect,
10589
10457
  reconnectDetails,
10458
+ preferredPublishOptions,
10459
+ preferredSubscribeOptions,
10590
10460
  });
10461
+ this.currentPublishOptions = publishOptions;
10591
10462
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
10592
10463
  if (callState) {
10593
10464
  this.state.updateFromSfuCallState(callState, sfuClient.sessionId, reconnectDetails);
@@ -10612,17 +10483,13 @@ class Call {
10612
10483
  connectionConfig,
10613
10484
  clientDetails,
10614
10485
  statsOptions,
10486
+ publishOptions: this.currentPublishOptions || [],
10615
10487
  closePreviousInstances: !performingMigration,
10616
10488
  });
10617
10489
  }
10618
10490
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
10619
10491
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
10620
- this.sfuStatsReporter?.sendTelemetryData({
10621
- data: {
10622
- oneofKind: 'connectionTimeSeconds',
10623
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
10624
- },
10625
- });
10492
+ this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
10626
10493
  }
10627
10494
  if (performingRejoin) {
10628
10495
  const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
@@ -10633,8 +10500,8 @@ class Call {
10633
10500
  }
10634
10501
  // device settings should be applied only once, we don't have to
10635
10502
  // re-apply them on later reconnections or server-side data fetches
10636
- if (!this.deviceSettingsAppliedOnce) {
10637
- await this.applyDeviceConfig(true);
10503
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
10504
+ await this.applyDeviceConfig(this.state.settings, true);
10638
10505
  this.deviceSettingsAppliedOnce = true;
10639
10506
  }
10640
10507
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -10643,6 +10510,8 @@ class Call {
10643
10510
  // we will spam the other participants with push notifications and `call.ring` events.
10644
10511
  delete this.joinCallData?.ring;
10645
10512
  delete this.joinCallData?.notify;
10513
+ // reset the reconnect strategy to unspecified after a successful reconnection
10514
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
10646
10515
  this.logger('info', `Joined call ${this.cid}`);
10647
10516
  };
10648
10517
  /**
@@ -10652,7 +10521,7 @@ class Call {
10652
10521
  this.getReconnectDetails = (migratingFromSfuId, previousSessionId) => {
10653
10522
  const strategy = this.reconnectStrategy;
10654
10523
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
10655
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
10524
+ const announcedTracks = this.publisher?.getAnnouncedTracksForReconnect() || [];
10656
10525
  return {
10657
10526
  strategy,
10658
10527
  announcedTracks,
@@ -10662,6 +10531,54 @@ class Call {
10662
10531
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
10663
10532
  };
10664
10533
  };
10534
+ /**
10535
+ * Prepares the preferred codec for the call.
10536
+ * This is an experimental client feature and subject to change.
10537
+ * @internal
10538
+ */
10539
+ this.getPreferredPublishOptions = () => {
10540
+ const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } = this.clientPublishOptions || {};
10541
+ if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers)
10542
+ return [];
10543
+ const codec = preferredCodec
10544
+ ? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
10545
+ : undefined;
10546
+ const preferredPublishOptions = [
10547
+ PublishOption.create({
10548
+ trackType: TrackType.VIDEO,
10549
+ codec,
10550
+ bitrate: preferredBitrate,
10551
+ maxSpatialLayers: maxSimulcastLayers,
10552
+ }),
10553
+ ];
10554
+ const screenShareSettings = this.screenShare.getSettings();
10555
+ if (screenShareSettings) {
10556
+ preferredPublishOptions.push(PublishOption.create({
10557
+ trackType: TrackType.SCREEN_SHARE,
10558
+ fps: screenShareSettings.maxFramerate,
10559
+ bitrate: screenShareSettings.maxBitrate,
10560
+ }));
10561
+ }
10562
+ return preferredPublishOptions;
10563
+ };
10564
+ /**
10565
+ * Prepares the preferred options for subscribing to tracks.
10566
+ * This is an experimental client feature and subject to change.
10567
+ * @internal
10568
+ */
10569
+ this.getPreferredSubscribeOptions = () => {
10570
+ const { subscriberCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
10571
+ if (!subscriberCodec || !subscriberFmtpLine)
10572
+ return [];
10573
+ return [
10574
+ SubscribeOption.create({
10575
+ trackType: TrackType.VIDEO,
10576
+ codecs: [
10577
+ { name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
10578
+ ],
10579
+ }),
10580
+ ];
10581
+ };
10665
10582
  /**
10666
10583
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
10667
10584
  * Uses the provided SFU client to restore the ICE connection.
@@ -10692,9 +10609,9 @@ class Call {
10692
10609
  * @internal
10693
10610
  */
10694
10611
  this.initPublisherAndSubscriber = (opts) => {
10695
- const { sfuClient, connectionConfig, clientDetails, statsOptions, closePreviousInstances, } = opts;
10612
+ const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, } = opts;
10696
10613
  if (closePreviousInstances && this.subscriber) {
10697
- this.subscriber.close();
10614
+ this.subscriber.dispose();
10698
10615
  }
10699
10616
  this.subscriber = new Subscriber({
10700
10617
  sfuClient,
@@ -10713,18 +10630,14 @@ class Call {
10713
10630
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
10714
10631
  if (!isAnonymous) {
10715
10632
  if (closePreviousInstances && this.publisher) {
10716
- this.publisher.close({ stopTracks: false });
10633
+ this.publisher.dispose();
10717
10634
  }
10718
- const audioSettings = this.state.settings?.audio;
10719
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
10720
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
10721
10635
  this.publisher = new Publisher({
10722
10636
  sfuClient,
10723
10637
  dispatcher: this.dispatcher,
10724
10638
  state: this.state,
10725
10639
  connectionConfig,
10726
- isDtxEnabled,
10727
- isRedEnabled,
10640
+ publishOptions,
10728
10641
  logTag: String(this.sfuClientTag),
10729
10642
  onUnrecoverableError: () => {
10730
10643
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -10871,47 +10784,31 @@ class Call {
10871
10784
  * @internal
10872
10785
  */
10873
10786
  this.reconnectFast = async () => {
10874
- let reconnectStartTime = Date.now();
10787
+ const reconnectStartTime = Date.now();
10875
10788
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
10876
10789
  this.state.setCallingState(exports.CallingState.RECONNECTING);
10877
10790
  await this.join(this.joinCallData);
10878
- this.sfuStatsReporter?.sendTelemetryData({
10879
- data: {
10880
- oneofKind: 'reconnection',
10881
- reconnection: {
10882
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10883
- strategy: WebsocketReconnectStrategy.FAST,
10884
- },
10885
- },
10886
- });
10791
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
10887
10792
  };
10888
10793
  /**
10889
10794
  * Initiates the reconnection flow with the "rejoin" strategy.
10890
10795
  * @internal
10891
10796
  */
10892
10797
  this.reconnectRejoin = async () => {
10893
- let reconnectStartTime = Date.now();
10798
+ const reconnectStartTime = Date.now();
10894
10799
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
10895
10800
  this.state.setCallingState(exports.CallingState.RECONNECTING);
10896
10801
  await this.join(this.joinCallData);
10897
10802
  await this.restorePublishedTracks();
10898
10803
  this.restoreSubscribedTracks();
10899
- this.sfuStatsReporter?.sendTelemetryData({
10900
- data: {
10901
- oneofKind: 'reconnection',
10902
- reconnection: {
10903
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10904
- strategy: WebsocketReconnectStrategy.REJOIN,
10905
- },
10906
- },
10907
- });
10804
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
10908
10805
  };
10909
10806
  /**
10910
10807
  * Initiates the reconnection flow with the "migrate" strategy.
10911
10808
  * @internal
10912
10809
  */
10913
10810
  this.reconnectMigrate = async () => {
10914
- let reconnectStartTime = Date.now();
10811
+ const reconnectStartTime = Date.now();
10915
10812
  const currentSfuClient = this.sfuClient;
10916
10813
  if (!currentSfuClient) {
10917
10814
  throw new Error('Cannot migrate without an active SFU client');
@@ -10945,20 +10842,12 @@ class Call {
10945
10842
  this.state.setCallingState(exports.CallingState.JOINED);
10946
10843
  }
10947
10844
  finally {
10948
- currentSubscriber?.close();
10949
- currentPublisher?.close({ stopTracks: false });
10845
+ currentSubscriber?.dispose();
10846
+ currentPublisher?.dispose();
10950
10847
  // and close the previous SFU client, without specifying close code
10951
10848
  currentSfuClient.close();
10952
10849
  }
10953
- this.sfuStatsReporter?.sendTelemetryData({
10954
- data: {
10955
- oneofKind: 'reconnection',
10956
- reconnection: {
10957
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10958
- strategy: WebsocketReconnectStrategy.MIGRATE,
10959
- },
10960
- },
10961
- });
10850
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.MIGRATE, (Date.now() - reconnectStartTime) / 1000);
10962
10851
  };
10963
10852
  /**
10964
10853
  * Registers the various event handlers for reconnection.
@@ -11035,23 +10924,16 @@ class Call {
11035
10924
  // the tracks need to be restored in their original order of publishing
11036
10925
  // otherwise, we might get `m-lines order mismatch` errors
11037
10926
  for (const trackType of this.trackPublishOrder) {
10927
+ let mediaStream;
11038
10928
  switch (trackType) {
11039
10929
  case TrackType.AUDIO:
11040
- const audioStream = this.microphone.state.mediaStream;
11041
- if (audioStream) {
11042
- await this.publishAudioStream(audioStream);
11043
- }
10930
+ mediaStream = this.microphone.state.mediaStream;
11044
10931
  break;
11045
10932
  case TrackType.VIDEO:
11046
- const videoStream = this.camera.state.mediaStream;
11047
- if (videoStream)
11048
- await this.publishVideoStream(videoStream);
10933
+ mediaStream = this.camera.state.mediaStream;
11049
10934
  break;
11050
10935
  case TrackType.SCREEN_SHARE:
11051
- const screenShareStream = this.screenShare.state.mediaStream;
11052
- if (screenShareStream) {
11053
- await this.publishScreenShareStream(screenShareStream);
11054
- }
10936
+ mediaStream = this.screenShare.state.mediaStream;
11055
10937
  break;
11056
10938
  // screen share audio can't exist without a screen share, so we handle it there
11057
10939
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -11061,6 +10943,8 @@ class Call {
11061
10943
  ensureExhausted(trackType, 'Unknown track type');
11062
10944
  break;
11063
10945
  }
10946
+ if (mediaStream)
10947
+ await this.publish(mediaStream, trackType);
11064
10948
  }
11065
10949
  };
11066
10950
  /**
@@ -11075,105 +10959,111 @@ class Call {
11075
10959
  };
11076
10960
  /**
11077
10961
  * Starts publishing the given video stream to the call.
11078
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
11079
- *
11080
- * Consecutive calls to this method will replace the previously published stream.
11081
- * The previous video stream will be stopped.
11082
- *
11083
- * @param videoStream the video stream to publish.
10962
+ * @deprecated use `call.publish()`.
11084
10963
  */
11085
10964
  this.publishVideoStream = async (videoStream) => {
11086
- if (!this.sfuClient)
11087
- throw new Error(`Call not joined yet.`);
11088
- // joining is in progress, and we should wait until the client is ready
11089
- await this.sfuClient.joinTask;
11090
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
11091
- throw new Error('No permission to publish video');
11092
- }
11093
- if (!this.publisher)
11094
- throw new Error('Publisher is not initialized');
11095
- const [videoTrack] = videoStream.getVideoTracks();
11096
- if (!videoTrack)
11097
- throw new Error('There is no video track in the stream');
11098
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
11099
- this.trackPublishOrder.push(TrackType.VIDEO);
11100
- }
11101
- await this.publisher.publishStream(videoStream, videoTrack, TrackType.VIDEO, this.publishOptions);
10965
+ await this.publish(videoStream, TrackType.VIDEO);
11102
10966
  };
11103
10967
  /**
11104
10968
  * Starts publishing the given audio stream to the call.
11105
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
11106
- *
11107
- * Consecutive calls to this method will replace the audio stream that is currently being published.
11108
- * The previous audio stream will be stopped.
11109
- *
11110
- * @param audioStream the audio stream to publish.
10969
+ * @deprecated use `call.publish()`
11111
10970
  */
11112
10971
  this.publishAudioStream = async (audioStream) => {
11113
- if (!this.sfuClient)
11114
- throw new Error(`Call not joined yet.`);
11115
- // joining is in progress, and we should wait until the client is ready
11116
- await this.sfuClient.joinTask;
11117
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
11118
- throw new Error('No permission to publish audio');
11119
- }
11120
- if (!this.publisher)
11121
- throw new Error('Publisher is not initialized');
11122
- const [audioTrack] = audioStream.getAudioTracks();
11123
- if (!audioTrack)
11124
- throw new Error('There is no audio track in the stream');
11125
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
11126
- this.trackPublishOrder.push(TrackType.AUDIO);
11127
- }
11128
- await this.publisher.publishStream(audioStream, audioTrack, TrackType.AUDIO);
10972
+ await this.publish(audioStream, TrackType.AUDIO);
11129
10973
  };
11130
10974
  /**
11131
10975
  * Starts publishing the given screen-share stream to the call.
11132
- *
11133
- * Consecutive calls to this method will replace the previous screen-share stream.
11134
- * The previous screen-share stream will be stopped.
11135
- *
11136
- * @param screenShareStream the screen-share stream to publish.
10976
+ * @deprecated use `call.publish()`
11137
10977
  */
11138
10978
  this.publishScreenShareStream = async (screenShareStream) => {
10979
+ await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
10980
+ };
10981
+ /**
10982
+ * Publishes the given media stream.
10983
+ *
10984
+ * @param mediaStream the media stream to publish.
10985
+ * @param trackType the type of the track to announce.
10986
+ */
10987
+ this.publish = async (mediaStream, trackType) => {
11139
10988
  if (!this.sfuClient)
11140
10989
  throw new Error(`Call not joined yet.`);
11141
10990
  // joining is in progress, and we should wait until the client is ready
11142
10991
  await this.sfuClient.joinTask;
11143
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
11144
- throw new Error('No permission to publish screen share');
10992
+ if (!this.permissionsContext.canPublish(trackType)) {
10993
+ throw new Error(`No permission to publish ${TrackType[trackType]}`);
11145
10994
  }
11146
10995
  if (!this.publisher)
11147
10996
  throw new Error('Publisher is not initialized');
11148
- const [screenShareTrack] = screenShareStream.getVideoTracks();
11149
- if (!screenShareTrack) {
11150
- throw new Error('There is no screen share track in the stream');
10997
+ const [track] = isAudioTrackType(trackType)
10998
+ ? mediaStream.getAudioTracks()
10999
+ : mediaStream.getVideoTracks();
11000
+ if (!track) {
11001
+ throw new Error(`There is no ${TrackType[trackType]} track in the stream`);
11151
11002
  }
11152
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
11153
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
11154
- }
11155
- const opts = {
11156
- screenShareSettings: this.screenShare.getSettings(),
11157
- };
11158
- await this.publisher.publishStream(screenShareStream, screenShareTrack, TrackType.SCREEN_SHARE, opts);
11159
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
11160
- if (screenShareAudioTrack) {
11161
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
11162
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
11003
+ if (track.readyState === 'ended') {
11004
+ throw new Error(`Can't publish ended tracks.`);
11005
+ }
11006
+ pushToIfMissing(this.trackPublishOrder, trackType);
11007
+ await this.publisher.publish(track, trackType);
11008
+ const trackTypes = [trackType];
11009
+ if (trackType === TrackType.SCREEN_SHARE) {
11010
+ const [audioTrack] = mediaStream.getAudioTracks();
11011
+ if (audioTrack) {
11012
+ pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
11013
+ await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
11014
+ trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
11163
11015
  }
11164
- await this.publisher.publishStream(screenShareStream, screenShareAudioTrack, TrackType.SCREEN_SHARE_AUDIO, opts);
11165
11016
  }
11017
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
11166
11018
  };
11167
11019
  /**
11168
11020
  * Stops publishing the given track type to the call, if it is currently being published.
11169
- * Underlying track will be stopped and removed from the publisher.
11170
11021
  *
11171
- * @param trackType the track type to stop publishing.
11172
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
11022
+ * @param trackTypes the track types to stop publishing.
11023
+ */
11024
+ this.stopPublish = async (...trackTypes) => {
11025
+ if (!this.sfuClient || !this.publisher)
11026
+ return;
11027
+ this.publisher.stopTracks(...trackTypes);
11028
+ await this.updateLocalStreamState(undefined, ...trackTypes);
11029
+ };
11030
+ /**
11031
+ * Updates the call state with the new stream.
11032
+ *
11033
+ * @param mediaStream the new stream to update the call state with.
11034
+ * If undefined, the stream will be removed from the call state.
11035
+ * @param trackTypes the track types to update the call state with.
11036
+ */
11037
+ this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
11038
+ if (!this.sfuClient || !this.sfuClient.sessionId)
11039
+ return;
11040
+ await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
11041
+ const { sessionId } = this.sfuClient;
11042
+ for (const trackType of trackTypes) {
11043
+ const streamStateProp = trackTypeToParticipantStreamKey(trackType);
11044
+ if (!streamStateProp)
11045
+ continue;
11046
+ this.state.updateParticipant(sessionId, (p) => ({
11047
+ publishedTracks: mediaStream
11048
+ ? pushToIfMissing([...p.publishedTracks], trackType)
11049
+ : p.publishedTracks.filter((t) => t !== trackType),
11050
+ [streamStateProp]: mediaStream,
11051
+ }));
11052
+ }
11053
+ };
11054
+ /**
11055
+ * Updates the preferred publishing options
11056
+ *
11057
+ * @internal
11058
+ * @param options the options to use.
11173
11059
  */
11174
- this.stopPublish = async (trackType, stopTrack = true) => {
11175
- this.logger('info', `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`);
11176
- await this.publisher?.unpublishStream(trackType, stopTrack);
11060
+ this.updatePublishOptions = (options) => {
11061
+ this.logger('warn', '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
11062
+ 'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.');
11063
+ if (this.state.callingState === exports.CallingState.JOINED) {
11064
+ this.logger('warn', 'Updating publish options after joining the call does not have an effect');
11065
+ }
11066
+ this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
11177
11067
  };
11178
11068
  /**
11179
11069
  * Notifies the SFU that a noise cancellation process has started.
@@ -11195,6 +11085,15 @@ class Call {
11195
11085
  this.logger('warn', 'Failed to notify stop of noise cancellation', err);
11196
11086
  });
11197
11087
  };
11088
+ /**
11089
+ * Notifies the SFU about the mute state of the given track types.
11090
+ * @internal
11091
+ */
11092
+ this.notifyTrackMuteState = async (muted, ...trackTypes) => {
11093
+ if (!this.sfuClient)
11094
+ return;
11095
+ await this.sfuClient.updateMuteStates(trackTypes.map((trackType) => ({ trackType, muted })));
11096
+ };
11198
11097
  /**
11199
11098
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
11200
11099
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -11658,70 +11557,14 @@ class Call {
11658
11557
  *
11659
11558
  * @internal
11660
11559
  */
11661
- this.applyDeviceConfig = async (status) => {
11662
- await this.initCamera({ setStatus: status }).catch((err) => {
11560
+ this.applyDeviceConfig = async (settings, publish) => {
11561
+ await this.camera.apply(settings.video, publish).catch((err) => {
11663
11562
  this.logger('warn', 'Camera init failed', err);
11664
11563
  });
11665
- await this.initMic({ setStatus: status }).catch((err) => {
11564
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
11666
11565
  this.logger('warn', 'Mic init failed', err);
11667
11566
  });
11668
11567
  };
11669
- this.initCamera = async (options) => {
11670
- // Wait for any in progress camera operation
11671
- await this.camera.statusChangeSettled();
11672
- if (this.state.localParticipant?.videoStream ||
11673
- !this.permissionsContext.hasPermission('send-video')) {
11674
- return;
11675
- }
11676
- // Set camera direction if it's not yet set
11677
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
11678
- let defaultDirection = 'front';
11679
- const backendSetting = this.state.settings?.video.camera_facing;
11680
- if (backendSetting) {
11681
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
11682
- }
11683
- this.camera.state.setDirection(defaultDirection);
11684
- }
11685
- // Set target resolution
11686
- const targetResolution = this.state.settings?.video.target_resolution;
11687
- if (targetResolution) {
11688
- await this.camera.selectTargetResolution(targetResolution);
11689
- }
11690
- if (options.setStatus) {
11691
- // Publish already that was set before we joined
11692
- if (this.camera.enabled &&
11693
- this.camera.state.mediaStream &&
11694
- !this.publisher?.isPublishing(TrackType.VIDEO)) {
11695
- await this.publishVideoStream(this.camera.state.mediaStream);
11696
- }
11697
- // Start camera if backend config specifies, and there is no local setting
11698
- if (this.camera.state.status === undefined &&
11699
- this.state.settings?.video.camera_default_on) {
11700
- await this.camera.enable();
11701
- }
11702
- }
11703
- };
11704
- this.initMic = async (options) => {
11705
- // Wait for any in progress mic operation
11706
- await this.microphone.statusChangeSettled();
11707
- if (this.state.localParticipant?.audioStream ||
11708
- !this.permissionsContext.hasPermission('send-audio')) {
11709
- return;
11710
- }
11711
- if (options.setStatus) {
11712
- // Publish media stream that was set before we joined
11713
- if (this.microphone.enabled &&
11714
- this.microphone.state.mediaStream &&
11715
- !this.publisher?.isPublishing(TrackType.AUDIO)) {
11716
- await this.publishAudioStream(this.microphone.state.mediaStream);
11717
- }
11718
- // Start mic if backend config specifies, and there is no local setting
11719
- if (this.microphone.state.status === undefined &&
11720
- this.state.settings?.audio.mic_default_on) {
11721
- await this.microphone.enable();
11722
- }
11723
- }
11724
- };
11725
11568
  /**
11726
11569
  * Will begin tracking the given element for visibility changes within the
11727
11570
  * configured viewport element (`call.setViewport`).
@@ -11870,109 +11713,6 @@ class Call {
11870
11713
  this.screenShare = new ScreenShareManager(this);
11871
11714
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
11872
11715
  }
11873
- async setup() {
11874
- await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11875
- if (this.initialized)
11876
- return;
11877
- this.leaveCallHooks.add(this.on('all', (event) => {
11878
- // update state with the latest event data
11879
- this.state.updateFromEvent(event);
11880
- }));
11881
- this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
11882
- this.registerEffects();
11883
- this.registerReconnectHandlers();
11884
- if (this.state.callingState === exports.CallingState.LEFT) {
11885
- this.state.setCallingState(exports.CallingState.IDLE);
11886
- }
11887
- this.initialized = true;
11888
- });
11889
- }
11890
- registerEffects() {
11891
- this.leaveCallHooks.add(
11892
- // handles updating the permissions context when the settings change.
11893
- createSubscription(this.state.settings$, (settings) => {
11894
- if (!settings)
11895
- return;
11896
- this.permissionsContext.setCallSettings(settings);
11897
- }));
11898
- this.leaveCallHooks.add(
11899
- // handle the case when the user permissions are modified.
11900
- createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
11901
- this.leaveCallHooks.add(
11902
- // handles the case when the user is blocked by the call owner.
11903
- createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
11904
- if (!blockedUserIds || blockedUserIds.length === 0)
11905
- return;
11906
- const currentUserId = this.currentUserId;
11907
- if (currentUserId && blockedUserIds.includes(currentUserId)) {
11908
- this.logger('info', 'Leaving call because of being blocked');
11909
- await this.leave({ reason: 'user blocked' }).catch((err) => {
11910
- this.logger('error', 'Error leaving call after being blocked', err);
11911
- });
11912
- }
11913
- }));
11914
- this.leaveCallHooks.add(
11915
- // cancel auto-drop when call is
11916
- createSubscription(this.state.session$, (session) => {
11917
- if (!this.ringing)
11918
- return;
11919
- const receiverId = this.clientStore.connectedUser?.id;
11920
- if (!receiverId)
11921
- return;
11922
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
11923
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
11924
- if (isAcceptedByMe || isRejectedByMe) {
11925
- this.cancelAutoDrop();
11926
- }
11927
- }));
11928
- this.leaveCallHooks.add(
11929
- // "ringing" mode effects and event handlers
11930
- createSubscription(this.ringingSubject, (isRinging) => {
11931
- if (!isRinging)
11932
- return;
11933
- const callSession = this.state.session;
11934
- const receiver_id = this.clientStore.connectedUser?.id;
11935
- const ended_at = callSession?.ended_at;
11936
- const created_by_id = this.state.createdBy?.id;
11937
- const rejected_by = callSession?.rejected_by;
11938
- const accepted_by = callSession?.accepted_by;
11939
- let leaveCallIdle = false;
11940
- if (ended_at) {
11941
- // call was ended before it was accepted or rejected so we should leave it to idle
11942
- leaveCallIdle = true;
11943
- }
11944
- else if (created_by_id && rejected_by) {
11945
- if (rejected_by[created_by_id]) {
11946
- // call was cancelled by the caller
11947
- leaveCallIdle = true;
11948
- }
11949
- }
11950
- else if (receiver_id && rejected_by) {
11951
- if (rejected_by[receiver_id]) {
11952
- // call was rejected by the receiver in some other device
11953
- leaveCallIdle = true;
11954
- }
11955
- }
11956
- else if (receiver_id && accepted_by) {
11957
- if (accepted_by[receiver_id]) {
11958
- // call was accepted by the receiver in some other device
11959
- leaveCallIdle = true;
11960
- }
11961
- }
11962
- if (leaveCallIdle) {
11963
- if (this.state.callingState !== exports.CallingState.IDLE) {
11964
- this.state.setCallingState(exports.CallingState.IDLE);
11965
- }
11966
- }
11967
- else {
11968
- if (this.state.callingState === exports.CallingState.IDLE) {
11969
- this.state.setCallingState(exports.CallingState.RINGING);
11970
- }
11971
- this.scheduleAutoDrop();
11972
- this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
11973
- }
11974
- }));
11975
- }
11976
11716
  /**
11977
11717
  * A flag indicating whether the call is "ringing" type of call.
11978
11718
  */
@@ -11991,15 +11731,6 @@ class Call {
11991
11731
  get isCreatedByMe() {
11992
11732
  return this.state.createdBy?.id === this.currentUserId;
11993
11733
  }
11994
- /**
11995
- * Updates the preferred publishing options
11996
- *
11997
- * @internal
11998
- * @param options the options to use.
11999
- */
12000
- updatePublishOptions(options) {
12001
- this.publishOptions = { ...this.publishOptions, ...options };
12002
- }
12003
11734
  }
12004
11735
 
12005
11736
  /**
@@ -13107,7 +12838,7 @@ class StreamClient {
13107
12838
  return await this.wsConnection.connect(this.defaultWSTimeout);
13108
12839
  };
13109
12840
  this.getUserAgent = () => {
13110
- const version = "1.14.0";
12841
+ const version = "1.15.1";
13111
12842
  return (this.userAgent ||
13112
12843
  `stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
13113
12844
  };
@@ -13407,7 +13138,7 @@ class StreamVideoClient {
13407
13138
  clientStore: this.writeableStateStore,
13408
13139
  });
13409
13140
  call.state.updateFromCallResponse(c.call);
13410
- await call.applyDeviceConfig(false);
13141
+ await call.applyDeviceConfig(c.call.settings, false);
13411
13142
  if (data.watch) {
13412
13143
  this.writeableStateStore.registerCall(call);
13413
13144
  }