@stream-io/video-client 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/index.browser.es.js +1532 -1784
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1512 -1783
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1532 -1784
  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 +36 -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
@@ -4,9 +4,9 @@ import { ServiceType, stackIntercept, RpcError } from '@protobuf-ts/runtime-rpc'
4
4
  import axios from 'axios';
5
5
  export { AxiosError } from 'axios';
6
6
  import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transport';
7
- import { UAParser } from 'ua-parser-js';
8
7
  import { ReplaySubject, combineLatest, BehaviorSubject, map, shareReplay, distinctUntilChanged, takeWhile, distinctUntilKeyChanged, fromEventPattern, startWith, concatMap, from, fromEvent, debounceTime, merge, pairwise, of } from 'rxjs';
9
- import * as SDP from 'sdp-transform';
8
+ import { parse } from 'sdp-transform';
9
+ import { UAParser } from 'ua-parser-js';
10
10
 
11
11
  /* tslint:disable */
12
12
  /* eslint-disable */
@@ -1224,23 +1224,33 @@ class VideoLayer$Type extends MessageType {
1224
1224
  */
1225
1225
  const VideoLayer = new VideoLayer$Type();
1226
1226
  // @generated message type with reflection information, may provide speed optimized methods
1227
- class PublishOptions$Type extends MessageType {
1227
+ class SubscribeOption$Type extends MessageType {
1228
1228
  constructor() {
1229
- super('stream.video.sfu.models.PublishOptions', [
1229
+ super('stream.video.sfu.models.SubscribeOption', [
1230
1230
  {
1231
1231
  no: 1,
1232
+ name: 'track_type',
1233
+ kind: 'enum',
1234
+ T: () => [
1235
+ 'stream.video.sfu.models.TrackType',
1236
+ TrackType,
1237
+ 'TRACK_TYPE_',
1238
+ ],
1239
+ },
1240
+ {
1241
+ no: 2,
1232
1242
  name: 'codecs',
1233
1243
  kind: 'message',
1234
1244
  repeat: 1 /*RepeatType.PACKED*/,
1235
- T: () => PublishOption,
1245
+ T: () => Codec,
1236
1246
  },
1237
1247
  ]);
1238
1248
  }
1239
1249
  }
1240
1250
  /**
1241
- * @generated MessageType for protobuf message stream.video.sfu.models.PublishOptions
1251
+ * @generated MessageType for protobuf message stream.video.sfu.models.SubscribeOption
1242
1252
  */
1243
- const PublishOptions = new PublishOptions$Type();
1253
+ const SubscribeOption = new SubscribeOption$Type();
1244
1254
  // @generated message type with reflection information, may provide speed optimized methods
1245
1255
  class PublishOption$Type extends MessageType {
1246
1256
  constructor() {
@@ -1270,6 +1280,13 @@ class PublishOption$Type extends MessageType {
1270
1280
  kind: 'scalar',
1271
1281
  T: 5 /*ScalarType.INT32*/,
1272
1282
  },
1283
+ {
1284
+ no: 7,
1285
+ name: 'video_dimension',
1286
+ kind: 'message',
1287
+ T: () => VideoDimension,
1288
+ },
1289
+ { no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
1273
1290
  ]);
1274
1291
  }
1275
1292
  }
@@ -1282,7 +1299,7 @@ class Codec$Type extends MessageType {
1282
1299
  constructor() {
1283
1300
  super('stream.video.sfu.models.Codec', [
1284
1301
  {
1285
- no: 11,
1302
+ no: 16,
1286
1303
  name: 'payload_type',
1287
1304
  kind: 'scalar',
1288
1305
  T: 13 /*ScalarType.UINT32*/,
@@ -1295,7 +1312,7 @@ class Codec$Type extends MessageType {
1295
1312
  T: 13 /*ScalarType.UINT32*/,
1296
1313
  },
1297
1314
  {
1298
- no: 13,
1315
+ no: 15,
1299
1316
  name: 'encoding_parameters',
1300
1317
  kind: 'scalar',
1301
1318
  T: 9 /*ScalarType.STRING*/,
@@ -1359,6 +1376,13 @@ class TrackInfo$Type extends MessageType {
1359
1376
  { no: 8, name: 'stereo', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1360
1377
  { no: 9, name: 'red', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1361
1378
  { no: 10, name: 'muted', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1379
+ { no: 11, name: 'codec', kind: 'message', T: () => Codec },
1380
+ {
1381
+ no: 12,
1382
+ name: 'publish_option_id',
1383
+ kind: 'scalar',
1384
+ T: 5 /*ScalarType.INT32*/,
1385
+ },
1362
1386
  ]);
1363
1387
  }
1364
1388
  }
@@ -1632,10 +1656,10 @@ var models = /*#__PURE__*/Object.freeze({
1632
1656
  get PeerType () { return PeerType; },
1633
1657
  Pin: Pin,
1634
1658
  PublishOption: PublishOption,
1635
- PublishOptions: PublishOptions,
1636
1659
  Sdk: Sdk,
1637
1660
  get SdkType () { return SdkType; },
1638
1661
  StreamQuality: StreamQuality,
1662
+ SubscribeOption: SubscribeOption,
1639
1663
  TrackInfo: TrackInfo,
1640
1664
  get TrackType () { return TrackType; },
1641
1665
  get TrackUnpublishReason () { return TrackUnpublishReason; },
@@ -2265,13 +2289,6 @@ class SfuEvent$Type extends MessageType {
2265
2289
  oneof: 'eventPayload',
2266
2290
  T: () => ParticipantMigrationComplete,
2267
2291
  },
2268
- {
2269
- no: 26,
2270
- name: 'codec_negotiation_complete',
2271
- kind: 'message',
2272
- oneof: 'eventPayload',
2273
- T: () => CodecNegotiationComplete,
2274
- },
2275
2292
  {
2276
2293
  no: 27,
2277
2294
  name: 'change_publish_options',
@@ -2292,10 +2309,12 @@ class ChangePublishOptions$Type extends MessageType {
2292
2309
  super('stream.video.sfu.event.ChangePublishOptions', [
2293
2310
  {
2294
2311
  no: 1,
2295
- name: 'publish_option',
2312
+ name: 'publish_options',
2296
2313
  kind: 'message',
2314
+ repeat: 1 /*RepeatType.PACKED*/,
2297
2315
  T: () => PublishOption,
2298
2316
  },
2317
+ { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2299
2318
  ]);
2300
2319
  }
2301
2320
  }
@@ -2304,15 +2323,15 @@ class ChangePublishOptions$Type extends MessageType {
2304
2323
  */
2305
2324
  const ChangePublishOptions = new ChangePublishOptions$Type();
2306
2325
  // @generated message type with reflection information, may provide speed optimized methods
2307
- class CodecNegotiationComplete$Type extends MessageType {
2326
+ class ChangePublishOptionsComplete$Type extends MessageType {
2308
2327
  constructor() {
2309
- super('stream.video.sfu.event.CodecNegotiationComplete', []);
2328
+ super('stream.video.sfu.event.ChangePublishOptionsComplete', []);
2310
2329
  }
2311
2330
  }
2312
2331
  /**
2313
- * @generated MessageType for protobuf message stream.video.sfu.event.CodecNegotiationComplete
2332
+ * @generated MessageType for protobuf message stream.video.sfu.event.ChangePublishOptionsComplete
2314
2333
  */
2315
- const CodecNegotiationComplete = new CodecNegotiationComplete$Type();
2334
+ const ChangePublishOptionsComplete = new ChangePublishOptionsComplete$Type();
2316
2335
  // @generated message type with reflection information, may provide speed optimized methods
2317
2336
  class ParticipantMigrationComplete$Type extends MessageType {
2318
2337
  constructor() {
@@ -2570,6 +2589,20 @@ class JoinRequest$Type extends MessageType {
2570
2589
  kind: 'message',
2571
2590
  T: () => ReconnectDetails,
2572
2591
  },
2592
+ {
2593
+ no: 9,
2594
+ name: 'preferred_publish_options',
2595
+ kind: 'message',
2596
+ repeat: 1 /*RepeatType.PACKED*/,
2597
+ T: () => PublishOption,
2598
+ },
2599
+ {
2600
+ no: 10,
2601
+ name: 'preferred_subscribe_options',
2602
+ kind: 'message',
2603
+ repeat: 1 /*RepeatType.PACKED*/,
2604
+ T: () => SubscribeOption,
2605
+ },
2573
2606
  ]);
2574
2607
  }
2575
2608
  }
@@ -2677,7 +2710,8 @@ class JoinResponse$Type extends MessageType {
2677
2710
  no: 4,
2678
2711
  name: 'publish_options',
2679
2712
  kind: 'message',
2680
- T: () => PublishOptions,
2713
+ repeat: 1 /*RepeatType.PACKED*/,
2714
+ T: () => PublishOption,
2681
2715
  },
2682
2716
  ]);
2683
2717
  }
@@ -2842,6 +2876,22 @@ class AudioSender$Type extends MessageType {
2842
2876
  constructor() {
2843
2877
  super('stream.video.sfu.event.AudioSender', [
2844
2878
  { no: 2, name: 'codec', kind: 'message', T: () => Codec },
2879
+ {
2880
+ no: 3,
2881
+ name: 'track_type',
2882
+ kind: 'enum',
2883
+ T: () => [
2884
+ 'stream.video.sfu.models.TrackType',
2885
+ TrackType,
2886
+ 'TRACK_TYPE_',
2887
+ ],
2888
+ },
2889
+ {
2890
+ no: 4,
2891
+ name: 'publish_option_id',
2892
+ kind: 'scalar',
2893
+ T: 5 /*ScalarType.INT32*/,
2894
+ },
2845
2895
  ]);
2846
2896
  }
2847
2897
  }
@@ -2894,6 +2944,22 @@ class VideoSender$Type extends MessageType {
2894
2944
  repeat: 1 /*RepeatType.PACKED*/,
2895
2945
  T: () => VideoLayerSetting,
2896
2946
  },
2947
+ {
2948
+ no: 4,
2949
+ name: 'track_type',
2950
+ kind: 'enum',
2951
+ T: () => [
2952
+ 'stream.video.sfu.models.TrackType',
2953
+ TrackType,
2954
+ 'TRACK_TYPE_',
2955
+ ],
2956
+ },
2957
+ {
2958
+ no: 5,
2959
+ name: 'publish_option_id',
2960
+ kind: 'scalar',
2961
+ T: 5 /*ScalarType.INT32*/,
2962
+ },
2897
2963
  ]);
2898
2964
  }
2899
2965
  }
@@ -2990,8 +3056,8 @@ var events = /*#__PURE__*/Object.freeze({
2990
3056
  CallEnded: CallEnded,
2991
3057
  CallGrantsUpdated: CallGrantsUpdated,
2992
3058
  ChangePublishOptions: ChangePublishOptions,
3059
+ ChangePublishOptionsComplete: ChangePublishOptionsComplete,
2993
3060
  ChangePublishQuality: ChangePublishQuality,
2994
- CodecNegotiationComplete: CodecNegotiationComplete,
2995
3061
  ConnectionQualityChanged: ConnectionQualityChanged,
2996
3062
  ConnectionQualityInfo: ConnectionQualityInfo,
2997
3063
  DominantSpeakerChanged: DominantSpeakerChanged,
@@ -3138,11 +3204,18 @@ const withHeaders = (headers) => {
3138
3204
  const withRequestLogger = (logger, level) => {
3139
3205
  return {
3140
3206
  interceptUnary: (next, method, input, options) => {
3141
- logger(level, `Calling SFU RPC method ${method.name}`, {
3142
- input,
3143
- options,
3144
- });
3145
- return next(method, input, options);
3207
+ let invocation;
3208
+ try {
3209
+ invocation = next(method, input, options);
3210
+ }
3211
+ finally {
3212
+ logger(level, `Invoked SFU RPC method ${method.name}`, {
3213
+ request: invocation?.request,
3214
+ headers: invocation?.requestHeaders,
3215
+ response: invocation?.response,
3216
+ });
3217
+ }
3218
+ return invocation;
3146
3219
  },
3147
3220
  };
3148
3221
  };
@@ -3359,665 +3432,139 @@ const retryable = async (rpc, signal) => {
3359
3432
  return result;
3360
3433
  };
3361
3434
 
3362
- const version = "1.14.0";
3363
- const [major, minor, patch] = version.split('.');
3364
- let sdkInfo = {
3365
- type: SdkType.PLAIN_JAVASCRIPT,
3366
- major,
3367
- minor,
3368
- patch,
3369
- };
3370
- let osInfo;
3371
- let deviceInfo;
3372
- let webRtcInfo;
3373
- let deviceState = { oneofKind: undefined };
3374
- const setSdkInfo = (info) => {
3375
- sdkInfo = info;
3376
- };
3377
- const getSdkInfo = () => {
3378
- return sdkInfo;
3379
- };
3380
- const setOSInfo = (info) => {
3381
- osInfo = info;
3382
- };
3383
- const getOSInfo = () => {
3384
- return osInfo;
3385
- };
3386
- const setDeviceInfo = (info) => {
3387
- deviceInfo = info;
3435
+ /**
3436
+ * Returns a generic SDP for the given direction.
3437
+ * We use this SDP to send it as part of our JoinRequest so that the SFU
3438
+ * can use it to determine the client's codec capabilities.
3439
+ *
3440
+ * @param direction the direction of the transceiver.
3441
+ */
3442
+ const getGenericSdp = async (direction) => {
3443
+ const tempPc = new RTCPeerConnection();
3444
+ tempPc.addTransceiver('video', { direction });
3445
+ tempPc.addTransceiver('audio', { direction });
3446
+ const offer = await tempPc.createOffer();
3447
+ const sdp = offer.sdp ?? '';
3448
+ tempPc.getTransceivers().forEach((t) => {
3449
+ t.stop?.();
3450
+ });
3451
+ tempPc.close();
3452
+ return sdp;
3388
3453
  };
3389
- const getDeviceInfo = () => {
3390
- return deviceInfo;
3454
+ /**
3455
+ * Returns whether the codec is an SVC codec.
3456
+ *
3457
+ * @param codecOrMimeType the codec to check.
3458
+ */
3459
+ const isSvcCodec = (codecOrMimeType) => {
3460
+ if (!codecOrMimeType)
3461
+ return false;
3462
+ codecOrMimeType = codecOrMimeType.toLowerCase();
3463
+ return (codecOrMimeType === 'vp9' ||
3464
+ codecOrMimeType === 'av1' ||
3465
+ codecOrMimeType === 'video/vp9' ||
3466
+ codecOrMimeType === 'video/av1');
3391
3467
  };
3392
- const getWebRTCInfo = () => {
3393
- return webRtcInfo;
3468
+
3469
+ const sfuEventKinds = {
3470
+ subscriberOffer: undefined,
3471
+ publisherAnswer: undefined,
3472
+ connectionQualityChanged: undefined,
3473
+ audioLevelChanged: undefined,
3474
+ iceTrickle: undefined,
3475
+ changePublishQuality: undefined,
3476
+ participantJoined: undefined,
3477
+ participantLeft: undefined,
3478
+ dominantSpeakerChanged: undefined,
3479
+ joinResponse: undefined,
3480
+ healthCheckResponse: undefined,
3481
+ trackPublished: undefined,
3482
+ trackUnpublished: undefined,
3483
+ error: undefined,
3484
+ callGrantsUpdated: undefined,
3485
+ goAway: undefined,
3486
+ iceRestart: undefined,
3487
+ pinsUpdated: undefined,
3488
+ callEnded: undefined,
3489
+ participantUpdated: undefined,
3490
+ participantMigrationComplete: undefined,
3491
+ changePublishOptions: undefined,
3394
3492
  };
3395
- const setWebRTCInfo = (info) => {
3396
- webRtcInfo = info;
3493
+ const isSfuEvent = (eventName) => {
3494
+ return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3397
3495
  };
3398
- const setThermalState = (state) => {
3399
- if (!osInfo) {
3400
- deviceState = { oneofKind: undefined };
3401
- return;
3402
- }
3403
- if (osInfo.name === 'android') {
3404
- const thermalState = AndroidThermalState[state] ||
3405
- AndroidThermalState.UNSPECIFIED;
3406
- deviceState = {
3407
- oneofKind: 'android',
3408
- android: {
3409
- thermalState,
3410
- isPowerSaverMode: deviceState?.oneofKind === 'android' &&
3411
- deviceState.android.isPowerSaverMode,
3412
- },
3496
+ class Dispatcher {
3497
+ constructor() {
3498
+ this.logger = getLogger(['Dispatcher']);
3499
+ this.subscribers = {};
3500
+ this.dispatch = (message, logTag = '0') => {
3501
+ const eventKind = message.eventPayload.oneofKind;
3502
+ if (!eventKind)
3503
+ return;
3504
+ const payload = message.eventPayload[eventKind];
3505
+ this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3506
+ const listeners = this.subscribers[eventKind];
3507
+ if (!listeners)
3508
+ return;
3509
+ for (const fn of listeners) {
3510
+ try {
3511
+ fn(payload);
3512
+ }
3513
+ catch (e) {
3514
+ this.logger('warn', 'Listener failed with error', e);
3515
+ }
3516
+ }
3517
+ };
3518
+ this.on = (eventName, fn) => {
3519
+ var _a;
3520
+ ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3521
+ return () => {
3522
+ this.off(eventName, fn);
3523
+ };
3524
+ };
3525
+ this.off = (eventName, fn) => {
3526
+ this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3413
3527
  };
3414
3528
  }
3415
- if (osInfo.name.toLowerCase() === 'ios') {
3416
- const thermalState = AppleThermalState[state] ||
3417
- AppleThermalState.UNSPECIFIED;
3418
- deviceState = {
3419
- oneofKind: 'apple',
3420
- apple: {
3421
- thermalState,
3422
- isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
3423
- deviceState.apple.isLowPowerModeEnabled,
3424
- },
3529
+ }
3530
+
3531
+ /**
3532
+ * A buffer for ICE Candidates. Used for ICE Trickle:
3533
+ * - https://bloggeek.me/webrtcglossary/trickle-ice/
3534
+ */
3535
+ class IceTrickleBuffer {
3536
+ constructor() {
3537
+ this.subscriberCandidates = new ReplaySubject();
3538
+ this.publisherCandidates = new ReplaySubject();
3539
+ this.push = (iceTrickle) => {
3540
+ const iceCandidate = toIceCandidate(iceTrickle);
3541
+ if (!iceCandidate)
3542
+ return;
3543
+ if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
3544
+ this.subscriberCandidates.next(iceCandidate);
3545
+ }
3546
+ else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
3547
+ this.publisherCandidates.next(iceCandidate);
3548
+ }
3549
+ else {
3550
+ const logger = getLogger(['sfu-client']);
3551
+ logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
3552
+ }
3553
+ };
3554
+ this.dispose = () => {
3555
+ this.subscriberCandidates.complete();
3556
+ this.publisherCandidates.complete();
3425
3557
  };
3426
3558
  }
3427
- };
3428
- const setPowerState = (powerMode) => {
3429
- if (!osInfo) {
3430
- deviceState = { oneofKind: undefined };
3431
- return;
3559
+ }
3560
+ const toIceCandidate = (iceTrickle) => {
3561
+ try {
3562
+ return JSON.parse(iceTrickle.iceCandidate);
3432
3563
  }
3433
- if (osInfo.name === 'android') {
3434
- deviceState = {
3435
- oneofKind: 'android',
3436
- android: {
3437
- thermalState: deviceState?.oneofKind === 'android'
3438
- ? deviceState.android.thermalState
3439
- : AndroidThermalState.UNSPECIFIED,
3440
- isPowerSaverMode: powerMode,
3441
- },
3442
- };
3443
- }
3444
- if (osInfo.name.toLowerCase() === 'ios') {
3445
- deviceState = {
3446
- oneofKind: 'apple',
3447
- apple: {
3448
- thermalState: deviceState?.oneofKind === 'apple'
3449
- ? deviceState.apple.thermalState
3450
- : AppleThermalState.UNSPECIFIED,
3451
- isLowPowerModeEnabled: powerMode,
3452
- },
3453
- };
3454
- }
3455
- };
3456
- const getDeviceState = () => {
3457
- return deviceState;
3458
- };
3459
- const getClientDetails = () => {
3460
- if (isReactNative()) {
3461
- // Since RN doesn't support web, sharing browser info is not required
3462
- return {
3463
- sdk: getSdkInfo(),
3464
- os: getOSInfo(),
3465
- device: getDeviceInfo(),
3466
- };
3467
- }
3468
- const userAgent = new UAParser(navigator.userAgent);
3469
- const { browser, os, device, cpu } = userAgent.getResult();
3470
- return {
3471
- sdk: getSdkInfo(),
3472
- browser: {
3473
- name: browser.name || navigator.userAgent,
3474
- version: browser.version || '',
3475
- },
3476
- os: {
3477
- name: os.name || '',
3478
- version: os.version || '',
3479
- architecture: cpu.architecture || '',
3480
- },
3481
- device: {
3482
- name: [device.vendor, device.model, device.type]
3483
- .filter(Boolean)
3484
- .join(' '),
3485
- version: '',
3486
- },
3487
- };
3488
- };
3489
-
3490
- /**
3491
- * Checks whether the current browser is Safari.
3492
- */
3493
- const isSafari = () => {
3494
- if (typeof navigator === 'undefined')
3495
- return false;
3496
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
3497
- };
3498
- /**
3499
- * Checks whether the current browser is Firefox.
3500
- */
3501
- const isFirefox = () => {
3502
- if (typeof navigator === 'undefined')
3503
- return false;
3504
- return navigator.userAgent?.includes('Firefox');
3505
- };
3506
- /**
3507
- * Checks whether the current browser is Google Chrome.
3508
- */
3509
- const isChrome = () => {
3510
- if (typeof navigator === 'undefined')
3511
- return false;
3512
- return navigator.userAgent?.includes('Chrome');
3513
- };
3514
-
3515
- var browsers = /*#__PURE__*/Object.freeze({
3516
- __proto__: null,
3517
- isChrome: isChrome,
3518
- isFirefox: isFirefox,
3519
- isSafari: isSafari
3520
- });
3521
-
3522
- /**
3523
- * Returns back a list of sorted codecs, with the preferred codec first.
3524
- *
3525
- * @param kind the kind of codec to get.
3526
- * @param preferredCodec the codec to prioritize (vp8, h264, vp9, av1...).
3527
- * @param codecToRemove the codec to exclude from the list.
3528
- * @param codecPreferencesSource the source of the codec preferences.
3529
- */
3530
- const getPreferredCodecs = (kind, preferredCodec, codecToRemove, codecPreferencesSource) => {
3531
- const source = codecPreferencesSource === 'receiver' ? RTCRtpReceiver : RTCRtpSender;
3532
- if (!('getCapabilities' in source))
3533
- return;
3534
- const capabilities = source.getCapabilities(kind);
3535
- if (!capabilities)
3536
- return;
3537
- const preferred = [];
3538
- const partiallyPreferred = [];
3539
- const unpreferred = [];
3540
- const preferredCodecMimeType = `${kind}/${preferredCodec.toLowerCase()}`;
3541
- const codecToRemoveMimeType = codecToRemove && `${kind}/${codecToRemove.toLowerCase()}`;
3542
- for (const codec of capabilities.codecs) {
3543
- const codecMimeType = codec.mimeType.toLowerCase();
3544
- const shouldRemoveCodec = codecMimeType === codecToRemoveMimeType;
3545
- if (shouldRemoveCodec)
3546
- continue; // skip this codec
3547
- const isPreferredCodec = codecMimeType === preferredCodecMimeType;
3548
- if (!isPreferredCodec) {
3549
- unpreferred.push(codec);
3550
- continue;
3551
- }
3552
- // h264 is a special case, we want to prioritize the baseline codec with
3553
- // profile-level-id is 42e01f and packetization-mode=0 for maximum
3554
- // cross-browser compatibility.
3555
- // this branch covers the other cases, such as vp8.
3556
- if (codecMimeType !== 'video/h264') {
3557
- preferred.push(codec);
3558
- continue;
3559
- }
3560
- const sdpFmtpLine = codec.sdpFmtpLine;
3561
- if (!sdpFmtpLine || !sdpFmtpLine.includes('profile-level-id=42')) {
3562
- // this is not the baseline h264 codec, prioritize it lower
3563
- partiallyPreferred.push(codec);
3564
- continue;
3565
- }
3566
- if (sdpFmtpLine.includes('packetization-mode=1')) {
3567
- preferred.unshift(codec);
3568
- }
3569
- else {
3570
- preferred.push(codec);
3571
- }
3572
- }
3573
- // return a sorted list of codecs, with the preferred codecs first
3574
- return [...preferred, ...partiallyPreferred, ...unpreferred];
3575
- };
3576
- /**
3577
- * Returns a generic SDP for the given direction.
3578
- * We use this SDP to send it as part of our JoinRequest so that the SFU
3579
- * can use it to determine client's codec capabilities.
3580
- *
3581
- * @param direction the direction of the transceiver.
3582
- */
3583
- const getGenericSdp = async (direction) => {
3584
- const tempPc = new RTCPeerConnection();
3585
- tempPc.addTransceiver('video', { direction });
3586
- tempPc.addTransceiver('audio', { direction });
3587
- const offer = await tempPc.createOffer();
3588
- const sdp = offer.sdp ?? '';
3589
- tempPc.getTransceivers().forEach((t) => {
3590
- t.stop?.();
3591
- });
3592
- tempPc.close();
3593
- return sdp;
3594
- };
3595
- /**
3596
- * Returns the optimal video codec for the device.
3597
- */
3598
- const getOptimalVideoCodec = (preferredCodec) => {
3599
- if (isReactNative()) {
3600
- const os = getOSInfo()?.name.toLowerCase();
3601
- if (os === 'android')
3602
- return preferredOr(preferredCodec, 'vp8');
3603
- if (os === 'ios' || os === 'ipados') {
3604
- return supportsH264Baseline() ? 'h264' : 'vp8';
3605
- }
3606
- return preferredOr(preferredCodec, 'h264');
3607
- }
3608
- if (isSafari())
3609
- return 'h264';
3610
- if (isFirefox())
3611
- return 'vp8';
3612
- return preferredOr(preferredCodec, 'vp8');
3613
- };
3614
- /**
3615
- * Determines if the platform supports the preferred codec.
3616
- * If not, it returns the fallback codec.
3617
- */
3618
- const preferredOr = (codec, fallback) => {
3619
- if (!codec)
3620
- return fallback;
3621
- if (!('getCapabilities' in RTCRtpSender))
3622
- return fallback;
3623
- const capabilities = RTCRtpSender.getCapabilities('video');
3624
- if (!capabilities)
3625
- return fallback;
3626
- // Safari and Firefox do not have a good support encoding to SVC codecs,
3627
- // so we disable it for them.
3628
- if (isSvcCodec(codec) && (isSafari() || isFirefox()))
3629
- return fallback;
3630
- const { codecs } = capabilities;
3631
- const codecMimeType = `video/${codec}`.toLowerCase();
3632
- return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
3633
- ? codec
3634
- : fallback;
3635
- };
3636
- /**
3637
- * Returns whether the platform supports the H264 baseline codec.
3638
- */
3639
- const supportsH264Baseline = () => {
3640
- if (!('getCapabilities' in RTCRtpSender))
3641
- return false;
3642
- const capabilities = RTCRtpSender.getCapabilities('video');
3643
- if (!capabilities)
3644
- return false;
3645
- return capabilities.codecs.some((c) => c.mimeType.toLowerCase() === 'video/h264' &&
3646
- c.sdpFmtpLine?.includes('profile-level-id=42e01f'));
3647
- };
3648
- /**
3649
- * Returns whether the codec is an SVC codec.
3650
- *
3651
- * @param codecOrMimeType the codec to check.
3652
- */
3653
- const isSvcCodec = (codecOrMimeType) => {
3654
- if (!codecOrMimeType)
3655
- return false;
3656
- codecOrMimeType = codecOrMimeType.toLowerCase();
3657
- return (codecOrMimeType === 'vp9' ||
3658
- codecOrMimeType === 'av1' ||
3659
- codecOrMimeType === 'video/vp9' ||
3660
- codecOrMimeType === 'video/av1');
3661
- };
3662
-
3663
- const sfuEventKinds = {
3664
- subscriberOffer: undefined,
3665
- publisherAnswer: undefined,
3666
- connectionQualityChanged: undefined,
3667
- audioLevelChanged: undefined,
3668
- iceTrickle: undefined,
3669
- changePublishQuality: undefined,
3670
- participantJoined: undefined,
3671
- participantLeft: undefined,
3672
- dominantSpeakerChanged: undefined,
3673
- joinResponse: undefined,
3674
- healthCheckResponse: undefined,
3675
- trackPublished: undefined,
3676
- trackUnpublished: undefined,
3677
- error: undefined,
3678
- callGrantsUpdated: undefined,
3679
- goAway: undefined,
3680
- iceRestart: undefined,
3681
- pinsUpdated: undefined,
3682
- callEnded: undefined,
3683
- participantUpdated: undefined,
3684
- participantMigrationComplete: undefined,
3685
- codecNegotiationComplete: undefined,
3686
- changePublishOptions: undefined,
3687
- };
3688
- const isSfuEvent = (eventName) => {
3689
- return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3690
- };
3691
- class Dispatcher {
3692
- constructor() {
3693
- this.logger = getLogger(['Dispatcher']);
3694
- this.subscribers = {};
3695
- this.dispatch = (message, logTag = '0') => {
3696
- const eventKind = message.eventPayload.oneofKind;
3697
- if (!eventKind)
3698
- return;
3699
- const payload = message.eventPayload[eventKind];
3700
- this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3701
- const listeners = this.subscribers[eventKind];
3702
- if (!listeners)
3703
- return;
3704
- for (const fn of listeners) {
3705
- try {
3706
- fn(payload);
3707
- }
3708
- catch (e) {
3709
- this.logger('warn', 'Listener failed with error', e);
3710
- }
3711
- }
3712
- };
3713
- this.on = (eventName, fn) => {
3714
- var _a;
3715
- ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3716
- return () => {
3717
- this.off(eventName, fn);
3718
- };
3719
- };
3720
- this.off = (eventName, fn) => {
3721
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3722
- };
3723
- this.offAll = (eventName) => {
3724
- if (eventName) {
3725
- this.subscribers[eventName] = [];
3726
- }
3727
- else {
3728
- this.subscribers = {};
3729
- }
3730
- };
3731
- }
3732
- }
3733
-
3734
- /**
3735
- * A buffer for ICE Candidates. Used for ICE Trickle:
3736
- * - https://bloggeek.me/webrtcglossary/trickle-ice/
3737
- */
3738
- class IceTrickleBuffer {
3739
- constructor() {
3740
- this.subscriberCandidates = new ReplaySubject();
3741
- this.publisherCandidates = new ReplaySubject();
3742
- this.push = (iceTrickle) => {
3743
- if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
3744
- this.subscriberCandidates.next(iceTrickle);
3745
- }
3746
- else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
3747
- this.publisherCandidates.next(iceTrickle);
3748
- }
3749
- else {
3750
- const logger = getLogger(['sfu-client']);
3751
- logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
3752
- }
3753
- };
3754
- }
3755
- }
3756
-
3757
- function getIceCandidate(candidate) {
3758
- if (!candidate.usernameFragment) {
3759
- // react-native-webrtc doesn't include usernameFragment in the candidate
3760
- const splittedCandidate = candidate.candidate.split(' ');
3761
- const ufragIndex = splittedCandidate.findIndex((s) => s === 'ufrag') + 1;
3762
- const usernameFragment = splittedCandidate[ufragIndex];
3763
- return JSON.stringify({ ...candidate, usernameFragment });
3764
- }
3765
- else {
3766
- return JSON.stringify(candidate.toJSON());
3767
- }
3768
- }
3769
-
3770
- const bitrateLookupTable = {
3771
- h264: {
3772
- 2160: 5000000,
3773
- 1440: 3000000,
3774
- 1080: 2000000,
3775
- 720: 1250000,
3776
- 540: 750000,
3777
- 360: 400000,
3778
- default: 1250000,
3779
- },
3780
- vp8: {
3781
- 2160: 5000000,
3782
- 1440: 2750000,
3783
- 1080: 2000000,
3784
- 720: 1250000,
3785
- 540: 600000,
3786
- 360: 350000,
3787
- default: 1250000,
3788
- },
3789
- vp9: {
3790
- 2160: 3000000,
3791
- 1440: 2000000,
3792
- 1080: 1500000,
3793
- 720: 1250000,
3794
- 540: 500000,
3795
- 360: 275000,
3796
- default: 1250000,
3797
- },
3798
- av1: {
3799
- 2160: 2000000,
3800
- 1440: 1550000,
3801
- 1080: 1000000,
3802
- 720: 600000,
3803
- 540: 350000,
3804
- 360: 200000,
3805
- default: 600000,
3806
- },
3807
- };
3808
- const getOptimalBitrate = (codec, frameHeight) => {
3809
- const codecLookup = bitrateLookupTable[codec];
3810
- if (!codecLookup)
3811
- throw new Error(`Unknown codec: ${codec}`);
3812
- let bitrate = codecLookup[frameHeight];
3813
- if (!bitrate) {
3814
- const keys = Object.keys(codecLookup).map(Number);
3815
- const nearest = keys.reduce((a, b) => Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a);
3816
- bitrate = codecLookup[nearest];
3817
- }
3818
- return bitrate ?? codecLookup.default;
3819
- };
3820
-
3821
- const DEFAULT_BITRATE = 1250000;
3822
- const defaultTargetResolution = {
3823
- bitrate: DEFAULT_BITRATE,
3824
- width: 1280,
3825
- height: 720,
3826
- };
3827
- const defaultBitratePerRid = {
3828
- q: 300000,
3829
- h: 750000,
3830
- f: DEFAULT_BITRATE,
3831
- };
3832
- /**
3833
- * In SVC, we need to send only one video encoding (layer).
3834
- * this layer will have the additional spatial and temporal layers
3835
- * defined via the scalabilityMode property.
3836
- *
3837
- * @param layers the layers to process.
3838
- */
3839
- const toSvcEncodings = (layers) => {
3840
- // we take the `f` layer, and we rename it to `q`.
3841
- return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
3842
- };
3843
- /**
3844
- * Converts the rid to a video quality.
3845
- */
3846
- const ridToVideoQuality = (rid) => {
3847
- return rid === 'q'
3848
- ? VideoQuality.LOW_UNSPECIFIED
3849
- : rid === 'h'
3850
- ? VideoQuality.MID
3851
- : VideoQuality.HIGH; // default to HIGH
3852
- };
3853
- /**
3854
- * Determines the most optimal video layers for simulcasting
3855
- * for the given track.
3856
- *
3857
- * @param videoTrack the video track to find optimal layers for.
3858
- * @param targetResolution the expected target resolution.
3859
- * @param codecInUse the codec in use.
3860
- * @param publishOptions the publish options for the track.
3861
- */
3862
- const findOptimalVideoLayers = (videoTrack, targetResolution = defaultTargetResolution, codecInUse, publishOptions) => {
3863
- const optimalVideoLayers = [];
3864
- const settings = videoTrack.getSettings();
3865
- const { width = 0, height = 0 } = settings;
3866
- const { scalabilityMode, bitrateDownscaleFactor = 2, maxSimulcastLayers = 3, } = publishOptions || {};
3867
- const maxBitrate = getComputedMaxBitrate(targetResolution, width, height, codecInUse, publishOptions);
3868
- let downscaleFactor = 1;
3869
- let bitrateFactor = 1;
3870
- const svcCodec = isSvcCodec(codecInUse);
3871
- const totalLayers = svcCodec ? 3 : Math.min(3, maxSimulcastLayers);
3872
- for (const rid of ['f', 'h', 'q'].slice(0, totalLayers)) {
3873
- const layer = {
3874
- active: true,
3875
- rid,
3876
- width: Math.round(width / downscaleFactor),
3877
- height: Math.round(height / downscaleFactor),
3878
- maxBitrate: Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
3879
- maxFramerate: 30,
3880
- };
3881
- if (svcCodec) {
3882
- // for SVC codecs, we need to set the scalability mode, and the
3883
- // codec will handle the rest (layers, temporal layers, etc.)
3884
- layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
3885
- }
3886
- else {
3887
- // for non-SVC codecs, we need to downscale proportionally (simulcast)
3888
- layer.scaleResolutionDownBy = downscaleFactor;
3889
- }
3890
- downscaleFactor *= 2;
3891
- bitrateFactor *= bitrateDownscaleFactor;
3892
- // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
3893
- // when deciding which layer to disable when CPU or bandwidth is constrained.
3894
- // Encodings should be ordered in increasing spatial resolution order.
3895
- optimalVideoLayers.unshift(layer);
3896
- }
3897
- // for simplicity, we start with all layers enabled, then this function
3898
- // will clear/reassign the layers that are not needed
3899
- return withSimulcastConstraints(settings, optimalVideoLayers);
3900
- };
3901
- /**
3902
- * Computes the maximum bitrate for a given resolution.
3903
- * If the current resolution is lower than the target resolution,
3904
- * we want to proportionally reduce the target bitrate.
3905
- * If the current resolution is higher than the target resolution,
3906
- * we want to use the target bitrate.
3907
- *
3908
- * @param targetResolution the target resolution.
3909
- * @param currentWidth the current width of the track.
3910
- * @param currentHeight the current height of the track.
3911
- * @param codecInUse the codec in use.
3912
- * @param publishOptions the publish options.
3913
- */
3914
- const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, codecInUse, publishOptions) => {
3915
- // if the current resolution is lower than the target resolution,
3916
- // we want to proportionally reduce the target bitrate
3917
- const { width: targetWidth, height: targetHeight, bitrate: targetBitrate, } = targetResolution;
3918
- const { preferredBitrate } = publishOptions || {};
3919
- const frameHeight = currentWidth > currentHeight ? currentHeight : currentWidth;
3920
- const bitrate = preferredBitrate ||
3921
- (codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
3922
- if (currentWidth < targetWidth || currentHeight < targetHeight) {
3923
- const currentPixels = currentWidth * currentHeight;
3924
- const targetPixels = targetWidth * targetHeight;
3925
- const reductionFactor = currentPixels / targetPixels;
3926
- return Math.round(bitrate * reductionFactor);
3927
- }
3928
- return bitrate;
3929
- };
3930
- /**
3931
- * Browsers have different simulcast constraints for different video resolutions.
3932
- *
3933
- * This function modifies the provided list of video layers according to the
3934
- * current implementation of simulcast constraints in the Chromium based browsers.
3935
- *
3936
- * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
3937
- */
3938
- const withSimulcastConstraints = (settings, optimalVideoLayers) => {
3939
- let layers;
3940
- const size = Math.max(settings.width || 0, settings.height || 0);
3941
- if (size <= 320) {
3942
- // provide only one layer 320x240 (q), the one with the highest quality
3943
- layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
3944
- }
3945
- else if (size <= 640) {
3946
- // provide two layers, 160x120 (q) and 640x480 (h)
3947
- layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
3948
- }
3949
- else {
3950
- // provide three layers for sizes > 640x480
3951
- layers = optimalVideoLayers;
3952
- }
3953
- const ridMapping = ['q', 'h', 'f'];
3954
- return layers.map((layer, index) => ({
3955
- ...layer,
3956
- rid: ridMapping[index], // reassign rid
3957
- }));
3958
- };
3959
- const findOptimalScreenSharingLayers = (videoTrack, publishOptions, defaultMaxBitrate = 3000000) => {
3960
- const { screenShareSettings: preferences } = publishOptions || {};
3961
- const settings = videoTrack.getSettings();
3962
- return [
3963
- {
3964
- active: true,
3965
- rid: 'q', // single track, start from 'q'
3966
- width: settings.width || 0,
3967
- height: settings.height || 0,
3968
- scaleResolutionDownBy: 1,
3969
- maxBitrate: preferences?.maxBitrate ?? defaultMaxBitrate,
3970
- maxFramerate: preferences?.maxFramerate ?? 30,
3971
- },
3972
- ];
3973
- };
3974
-
3975
- const ensureExhausted = (x, message) => {
3976
- getLogger(['helpers'])('warn', message, x);
3977
- };
3978
-
3979
- const trackTypeToParticipantStreamKey = (trackType) => {
3980
- switch (trackType) {
3981
- case TrackType.SCREEN_SHARE:
3982
- return 'screenShareStream';
3983
- case TrackType.SCREEN_SHARE_AUDIO:
3984
- return 'screenShareAudioStream';
3985
- case TrackType.VIDEO:
3986
- return 'videoStream';
3987
- case TrackType.AUDIO:
3988
- return 'audioStream';
3989
- case TrackType.UNSPECIFIED:
3990
- throw new Error('Track type is unspecified');
3991
- default:
3992
- ensureExhausted(trackType, 'Unknown track type');
3993
- }
3994
- };
3995
- const muteTypeToTrackType = (muteType) => {
3996
- switch (muteType) {
3997
- case 'audio':
3998
- return TrackType.AUDIO;
3999
- case 'video':
4000
- return TrackType.VIDEO;
4001
- case 'screenshare':
4002
- return TrackType.SCREEN_SHARE;
4003
- case 'screenshare_audio':
4004
- return TrackType.SCREEN_SHARE_AUDIO;
4005
- default:
4006
- ensureExhausted(muteType, 'Unknown mute type');
4007
- }
4008
- };
4009
- const toTrackType = (trackType) => {
4010
- switch (trackType) {
4011
- case 'TRACK_TYPE_AUDIO':
4012
- return TrackType.AUDIO;
4013
- case 'TRACK_TYPE_VIDEO':
4014
- return TrackType.VIDEO;
4015
- case 'TRACK_TYPE_SCREEN_SHARE':
4016
- return TrackType.SCREEN_SHARE;
4017
- case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
4018
- return TrackType.SCREEN_SHARE_AUDIO;
4019
- default:
4020
- return undefined;
3564
+ catch (e) {
3565
+ const logger = getLogger(['sfu-client']);
3566
+ logger('error', `Failed to parse ICE Trickle`, e, iceTrickle);
3567
+ return undefined;
4021
3568
  }
4022
3569
  };
4023
3570
 
@@ -5585,198 +5132,446 @@ class CallState {
5585
5132
  }
5586
5133
  }
5587
5134
 
5588
- const getRtpMap = (line) => {
5589
- // Example: a=rtpmap:110 opus/48000/2
5590
- const rtpRegex = /^a=rtpmap:(\d*) ([\w\-.]*)(?:\s*\/(\d*)(?:\s*\/(\S*))?)?/;
5591
- // 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.
5592
- const rtpMatch = rtpRegex.exec(line);
5593
- if (rtpMatch) {
5594
- return {
5595
- original: rtpMatch[0],
5596
- payload: rtpMatch[1],
5597
- codec: rtpMatch[2],
5598
- };
5599
- }
5600
- };
5601
- const getFmtp = (line) => {
5602
- // Example: a=fmtp:111 minptime=10; useinbandfec=1
5603
- const fmtpRegex = /^a=fmtp:(\d*) (.*)/;
5604
- const fmtpMatch = fmtpRegex.exec(line);
5605
- // The first captured group is the payload type number, the second captured group is any additional parameters.
5606
- if (fmtpMatch) {
5607
- return {
5608
- original: fmtpMatch[0],
5609
- payload: fmtpMatch[1],
5610
- config: fmtpMatch[2],
5611
- };
5612
- }
5613
- };
5614
5135
  /**
5615
- * gets the media section for the specified media type.
5616
- * The media section contains the media type, port, codec, and payload type.
5617
- * Example: m=video 9 UDP/TLS/RTP/SAVPF 100 101 96 97 35 36 102 125 127
5136
+ * A base class for the `Publisher` and `Subscriber` classes.
5137
+ * @internal
5618
5138
  */
5619
- const getMedia = (line, mediaType) => {
5620
- const regex = new RegExp(`(m=${mediaType} \\d+ [\\w/]+) ([\\d\\s]+)`);
5621
- const match = regex.exec(line);
5622
- if (match) {
5623
- return {
5624
- original: match[0],
5625
- mediaWithPorts: match[1],
5626
- codecOrder: match[2],
5139
+ class BasePeerConnection {
5140
+ /**
5141
+ * Constructs a new `BasePeerConnection` instance.
5142
+ */
5143
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onUnrecoverableError, logTag, }) {
5144
+ this.isIceRestarting = false;
5145
+ this.subscriptions = [];
5146
+ /**
5147
+ * Disposes the `RTCPeerConnection` instance.
5148
+ */
5149
+ this.dispose = () => {
5150
+ this.detachEventHandlers();
5151
+ this.pc.close();
5627
5152
  };
5628
- }
5629
- };
5630
- const getMediaSection = (sdp, mediaType) => {
5631
- let media;
5632
- const rtpMap = [];
5633
- const fmtp = [];
5634
- let isTheRequiredMediaSection = false;
5635
- sdp.split(/(\r\n|\r|\n)/).forEach((line) => {
5636
- const isValidLine = /^([a-z])=(.*)/.test(line);
5637
- if (!isValidLine)
5638
- return;
5639
- /*
5640
- NOTE: according to https://www.rfc-editor.org/rfc/rfc8866.pdf
5641
- 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
5642
- */
5643
- const type = line[0];
5644
- if (type === 'm') {
5645
- const _media = getMedia(line, mediaType);
5646
- isTheRequiredMediaSection = !!_media;
5647
- if (_media) {
5648
- media = _media;
5153
+ /**
5154
+ * Handles events synchronously.
5155
+ * Consecutive events are queued and executed one after the other.
5156
+ */
5157
+ this.on = (event, fn) => {
5158
+ this.subscriptions.push(this.dispatcher.on(event, (e) => {
5159
+ withoutConcurrency(`pc.${event}`, async () => fn(e)).catch((err) => {
5160
+ this.logger('warn', `Error handling ${event}`, err);
5161
+ });
5162
+ }));
5163
+ };
5164
+ /**
5165
+ * Appends the trickled ICE candidates to the `RTCPeerConnection`.
5166
+ */
5167
+ this.addTrickledIceCandidates = () => {
5168
+ const { iceTrickleBuffer } = this.sfuClient;
5169
+ const observable = this.peerType === PeerType.SUBSCRIBER
5170
+ ? iceTrickleBuffer.subscriberCandidates
5171
+ : iceTrickleBuffer.publisherCandidates;
5172
+ this.unsubscribeIceTrickle?.();
5173
+ this.unsubscribeIceTrickle = createSafeAsyncSubscription(observable, async (candidate) => {
5174
+ return this.pc.addIceCandidate(candidate).catch((e) => {
5175
+ this.logger('warn', `ICE candidate error`, e, candidate);
5176
+ });
5177
+ });
5178
+ };
5179
+ /**
5180
+ * Sets the SFU client to use.
5181
+ *
5182
+ * @param sfuClient the SFU client to use.
5183
+ */
5184
+ this.setSfuClient = (sfuClient) => {
5185
+ this.sfuClient = sfuClient;
5186
+ };
5187
+ /**
5188
+ * Returns the result of the `RTCPeerConnection.getStats()` method
5189
+ * @param selector an optional `MediaStreamTrack` to get the stats for.
5190
+ */
5191
+ this.getStats = (selector) => {
5192
+ return this.pc.getStats(selector);
5193
+ };
5194
+ /**
5195
+ * Handles the ICECandidate event and
5196
+ * Initiates an ICE Trickle process with the SFU.
5197
+ */
5198
+ this.onIceCandidate = (e) => {
5199
+ const { candidate } = e;
5200
+ if (!candidate) {
5201
+ this.logger('debug', 'null ice candidate');
5202
+ return;
5649
5203
  }
5650
- }
5651
- else if (isTheRequiredMediaSection && type === 'a') {
5652
- const rtpMapLine = getRtpMap(line);
5653
- const fmtpLine = getFmtp(line);
5654
- if (rtpMapLine) {
5655
- rtpMap.push(rtpMapLine);
5204
+ const iceCandidate = this.toJSON(candidate);
5205
+ this.sfuClient
5206
+ .iceTrickle({ peerType: this.peerType, iceCandidate })
5207
+ .catch((err) => this.logger('warn', `ICETrickle failed`, err));
5208
+ };
5209
+ /**
5210
+ * Converts the ICE candidate to a JSON string.
5211
+ */
5212
+ this.toJSON = (candidate) => {
5213
+ if (!candidate.usernameFragment) {
5214
+ // react-native-webrtc doesn't include usernameFragment in the candidate
5215
+ const segments = candidate.candidate.split(' ');
5216
+ const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
5217
+ const usernameFragment = segments[ufragIndex];
5218
+ return JSON.stringify({ ...candidate, usernameFragment });
5656
5219
  }
5657
- else if (fmtpLine) {
5658
- fmtp.push(fmtpLine);
5220
+ return JSON.stringify(candidate.toJSON());
5221
+ };
5222
+ /**
5223
+ * Handles the ICE connection state change event.
5224
+ */
5225
+ this.onIceConnectionStateChange = () => {
5226
+ const state = this.pc.iceConnectionState;
5227
+ this.logger('debug', `ICE connection state changed`, state);
5228
+ if (this.state.callingState === CallingState.RECONNECTING)
5229
+ return;
5230
+ // do nothing when ICE is restarting
5231
+ if (this.isIceRestarting)
5232
+ return;
5233
+ if (state === 'failed' || state === 'disconnected') {
5234
+ this.logger('debug', `Attempting to restart ICE`);
5235
+ this.restartIce().catch((e) => {
5236
+ this.logger('error', `ICE restart failed`, e);
5237
+ this.onUnrecoverableError?.();
5238
+ });
5239
+ }
5240
+ };
5241
+ /**
5242
+ * Handles the ICE candidate error event.
5243
+ */
5244
+ this.onIceCandidateError = (e) => {
5245
+ const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
5246
+ `${e.errorCode}: ${e.errorText}`;
5247
+ const iceState = this.pc.iceConnectionState;
5248
+ const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
5249
+ this.logger(logLevel, `ICE Candidate error`, errorMessage);
5250
+ };
5251
+ /**
5252
+ * Handles the ICE gathering state change event.
5253
+ */
5254
+ this.onIceGatherChange = () => {
5255
+ this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
5256
+ };
5257
+ /**
5258
+ * Handles the signaling state change event.
5259
+ */
5260
+ this.onSignalingChange = () => {
5261
+ this.logger('debug', `Signaling state changed`, this.pc.signalingState);
5262
+ };
5263
+ this.peerType = peerType;
5264
+ this.sfuClient = sfuClient;
5265
+ this.state = state;
5266
+ this.dispatcher = dispatcher;
5267
+ this.onUnrecoverableError = onUnrecoverableError;
5268
+ this.logger = getLogger([
5269
+ peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
5270
+ logTag,
5271
+ ]);
5272
+ this.pc = new RTCPeerConnection(connectionConfig);
5273
+ this.pc.addEventListener('icecandidate', this.onIceCandidate);
5274
+ this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5275
+ this.pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5276
+ this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
5277
+ this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
5278
+ }
5279
+ /**
5280
+ * Detaches the event handlers from the `RTCPeerConnection`.
5281
+ */
5282
+ detachEventHandlers() {
5283
+ this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5284
+ this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5285
+ this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
5286
+ this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5287
+ this.pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
5288
+ this.unsubscribeIceTrickle?.();
5289
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
5290
+ }
5291
+ }
5292
+
5293
+ class TransceiverCache {
5294
+ constructor() {
5295
+ this.cache = [];
5296
+ this.layers = [];
5297
+ /**
5298
+ * An array maintaining the order how transceivers were added to the peer connection.
5299
+ * This is needed because some browsers (Firefox) don't reliably report
5300
+ * trackId and `mid` parameters.
5301
+ */
5302
+ this.transceiverOrder = [];
5303
+ /**
5304
+ * Adds a transceiver to the cache.
5305
+ */
5306
+ this.add = (publishOption, transceiver) => {
5307
+ this.cache.push({ publishOption, transceiver });
5308
+ this.transceiverOrder.push(transceiver);
5309
+ };
5310
+ /**
5311
+ * Gets the transceiver for the given publish option.
5312
+ */
5313
+ this.get = (publishOption) => {
5314
+ return this.findTransceiver(publishOption)?.transceiver;
5315
+ };
5316
+ /**
5317
+ * Gets the last transceiver for the given track type and publish option id.
5318
+ */
5319
+ this.getWith = (trackType, id) => {
5320
+ return this.findTransceiver({ trackType, id })?.transceiver;
5321
+ };
5322
+ /**
5323
+ * Checks if the cache has the given publish option.
5324
+ */
5325
+ this.has = (publishOption) => {
5326
+ return !!this.get(publishOption);
5327
+ };
5328
+ /**
5329
+ * Finds the first transceiver that satisfies the given predicate.
5330
+ */
5331
+ this.find = (predicate) => {
5332
+ return this.cache.find(predicate);
5333
+ };
5334
+ /**
5335
+ * Provides all the items in the cache.
5336
+ */
5337
+ this.items = () => {
5338
+ return this.cache;
5339
+ };
5340
+ /**
5341
+ * Init index of the transceiver in the cache.
5342
+ */
5343
+ this.indexOf = (transceiver) => {
5344
+ return this.transceiverOrder.indexOf(transceiver);
5345
+ };
5346
+ /**
5347
+ * Gets cached video layers for the given track.
5348
+ */
5349
+ this.getLayers = (publishOption) => {
5350
+ const entry = this.layers.find((item) => item.publishOption.id === publishOption.id &&
5351
+ item.publishOption.trackType === publishOption.trackType);
5352
+ return entry?.layers;
5353
+ };
5354
+ /**
5355
+ * Sets the video layers for the given track.
5356
+ */
5357
+ this.setLayers = (publishOption, layers = []) => {
5358
+ const entry = this.findLayer(publishOption);
5359
+ if (entry) {
5360
+ entry.layers = layers;
5361
+ }
5362
+ else {
5363
+ this.layers.push({ publishOption, layers });
5659
5364
  }
5660
- }
5661
- });
5662
- if (media) {
5663
- return {
5664
- media,
5665
- rtpMap,
5666
- fmtp,
5667
5365
  };
5366
+ this.findTransceiver = (publishOption) => {
5367
+ return this.cache.find((item) => item.publishOption.id === publishOption.id &&
5368
+ item.publishOption.trackType === publishOption.trackType);
5369
+ };
5370
+ this.findLayer = (publishOption) => {
5371
+ return this.layers.find((item) => item.publishOption.id === publishOption.id &&
5372
+ item.publishOption.trackType === publishOption.trackType);
5373
+ };
5374
+ }
5375
+ }
5376
+
5377
+ const ensureExhausted = (x, message) => {
5378
+ getLogger(['helpers'])('warn', message, x);
5379
+ };
5380
+
5381
+ const trackTypeToParticipantStreamKey = (trackType) => {
5382
+ switch (trackType) {
5383
+ case TrackType.SCREEN_SHARE:
5384
+ return 'screenShareStream';
5385
+ case TrackType.SCREEN_SHARE_AUDIO:
5386
+ return 'screenShareAudioStream';
5387
+ case TrackType.VIDEO:
5388
+ return 'videoStream';
5389
+ case TrackType.AUDIO:
5390
+ return 'audioStream';
5391
+ case TrackType.UNSPECIFIED:
5392
+ throw new Error('Track type is unspecified');
5393
+ default:
5394
+ ensureExhausted(trackType, 'Unknown track type');
5395
+ }
5396
+ };
5397
+ const muteTypeToTrackType = (muteType) => {
5398
+ switch (muteType) {
5399
+ case 'audio':
5400
+ return TrackType.AUDIO;
5401
+ case 'video':
5402
+ return TrackType.VIDEO;
5403
+ case 'screenshare':
5404
+ return TrackType.SCREEN_SHARE;
5405
+ case 'screenshare_audio':
5406
+ return TrackType.SCREEN_SHARE_AUDIO;
5407
+ default:
5408
+ ensureExhausted(muteType, 'Unknown mute type');
5409
+ }
5410
+ };
5411
+ const toTrackType = (trackType) => {
5412
+ switch (trackType) {
5413
+ case 'TRACK_TYPE_AUDIO':
5414
+ return TrackType.AUDIO;
5415
+ case 'TRACK_TYPE_VIDEO':
5416
+ return TrackType.VIDEO;
5417
+ case 'TRACK_TYPE_SCREEN_SHARE':
5418
+ return TrackType.SCREEN_SHARE;
5419
+ case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
5420
+ return TrackType.SCREEN_SHARE_AUDIO;
5421
+ default:
5422
+ return undefined;
5668
5423
  }
5669
5424
  };
5425
+ const isAudioTrackType = (trackType) => trackType === TrackType.AUDIO || trackType === TrackType.SCREEN_SHARE_AUDIO;
5426
+
5427
+ const defaultBitratePerRid = {
5428
+ q: 300000,
5429
+ h: 750000,
5430
+ f: 1250000,
5431
+ };
5432
+ /**
5433
+ * In SVC, we need to send only one video encoding (layer).
5434
+ * this layer will have the additional spatial and temporal layers
5435
+ * defined via the scalabilityMode property.
5436
+ *
5437
+ * @param layers the layers to process.
5438
+ */
5439
+ const toSvcEncodings = (layers) => {
5440
+ if (!layers)
5441
+ return;
5442
+ // we take the highest quality layer, and we assign it to `q` encoder.
5443
+ const withRid = (rid) => (l) => l.rid === rid;
5444
+ const highestLayer = layers.find(withRid('f')) ||
5445
+ layers.find(withRid('h')) ||
5446
+ layers.find(withRid('q'));
5447
+ return [{ ...highestLayer, rid: 'q' }];
5448
+ };
5449
+ /**
5450
+ * Converts the rid to a video quality.
5451
+ */
5452
+ const ridToVideoQuality = (rid) => {
5453
+ return rid === 'q'
5454
+ ? VideoQuality.LOW_UNSPECIFIED
5455
+ : rid === 'h'
5456
+ ? VideoQuality.MID
5457
+ : VideoQuality.HIGH; // default to HIGH
5458
+ };
5670
5459
  /**
5671
- * Gets the fmtp line corresponding to opus
5460
+ * Converts the given video layers to SFU video layers.
5672
5461
  */
5673
- const getOpusFmtp = (sdp) => {
5674
- const section = getMediaSection(sdp, 'audio');
5675
- const rtpMap = section?.rtpMap.find((r) => r.codec.toLowerCase() === 'opus');
5676
- const codecId = rtpMap?.payload;
5677
- if (codecId) {
5678
- return section?.fmtp.find((f) => f.payload === codecId);
5462
+ const toVideoLayers = (layers = []) => {
5463
+ return layers.map((layer) => ({
5464
+ rid: layer.rid || '',
5465
+ bitrate: layer.maxBitrate || 0,
5466
+ fps: layer.maxFramerate || 0,
5467
+ quality: ridToVideoQuality(layer.rid || ''),
5468
+ videoDimension: { width: layer.width, height: layer.height },
5469
+ }));
5470
+ };
5471
+ /**
5472
+ * Converts the spatial and temporal layers to a scalability mode.
5473
+ */
5474
+ const toScalabilityMode = (spatialLayers, temporalLayers) => `L${spatialLayers}T${temporalLayers}${spatialLayers > 1 ? '_KEY' : ''}`;
5475
+ /**
5476
+ * Determines the most optimal video layers for the given track.
5477
+ *
5478
+ * @param videoTrack the video track to find optimal layers for.
5479
+ * @param publishOption the publish options for the track.
5480
+ */
5481
+ const computeVideoLayers = (videoTrack, publishOption) => {
5482
+ if (isAudioTrackType(publishOption.trackType))
5483
+ return;
5484
+ const optimalVideoLayers = [];
5485
+ const settings = videoTrack.getSettings();
5486
+ const { width = 0, height = 0 } = settings;
5487
+ const { bitrate, codec, fps, maxSpatialLayers = 3, maxTemporalLayers = 3, videoDimension = { width: 1280, height: 720 }, } = publishOption;
5488
+ const maxBitrate = getComputedMaxBitrate(videoDimension, width, height, bitrate);
5489
+ let downscaleFactor = 1;
5490
+ let bitrateFactor = 1;
5491
+ const svcCodec = isSvcCodec(codec?.name);
5492
+ for (const rid of ['f', 'h', 'q'].slice(0, maxSpatialLayers)) {
5493
+ const layer = {
5494
+ active: true,
5495
+ rid,
5496
+ width: Math.round(width / downscaleFactor),
5497
+ height: Math.round(height / downscaleFactor),
5498
+ maxBitrate: maxBitrate / bitrateFactor || defaultBitratePerRid[rid],
5499
+ maxFramerate: fps,
5500
+ };
5501
+ if (svcCodec) {
5502
+ // for SVC codecs, we need to set the scalability mode, and the
5503
+ // codec will handle the rest (layers, temporal layers, etc.)
5504
+ layer.scalabilityMode = toScalabilityMode(maxSpatialLayers, maxTemporalLayers);
5505
+ }
5506
+ else {
5507
+ // for non-SVC codecs, we need to downscale proportionally (simulcast)
5508
+ layer.scaleResolutionDownBy = downscaleFactor;
5509
+ }
5510
+ downscaleFactor *= 2;
5511
+ bitrateFactor *= 2;
5512
+ // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
5513
+ // when deciding which layer to disable when CPU or bandwidth is constrained.
5514
+ // Encodings should be ordered in increasing spatial resolution order.
5515
+ optimalVideoLayers.unshift(layer);
5679
5516
  }
5517
+ // for simplicity, we start with all layers enabled, then this function
5518
+ // will clear/reassign the layers that are not needed
5519
+ return withSimulcastConstraints(settings, optimalVideoLayers);
5680
5520
  };
5681
5521
  /**
5682
- * Returns an SDP with DTX enabled or disabled.
5522
+ * Computes the maximum bitrate for a given resolution.
5523
+ * If the current resolution is lower than the target resolution,
5524
+ * we want to proportionally reduce the target bitrate.
5525
+ * If the current resolution is higher than the target resolution,
5526
+ * we want to use the target bitrate.
5527
+ *
5528
+ * @param targetResolution the target resolution.
5529
+ * @param currentWidth the current width of the track.
5530
+ * @param currentHeight the current height of the track.
5531
+ * @param bitrate the target bitrate.
5683
5532
  */
5684
- const toggleDtx = (sdp, enable) => {
5685
- const opusFmtp = getOpusFmtp(sdp);
5686
- if (!opusFmtp)
5687
- return sdp;
5688
- const matchDtx = /usedtx=(\d)/.exec(opusFmtp.config);
5689
- const requiredDtxConfig = `usedtx=${enable ? '1' : '0'}`;
5690
- const newFmtp = matchDtx
5691
- ? opusFmtp.original.replace(/usedtx=(\d)/, requiredDtxConfig)
5692
- : `${opusFmtp.original};${requiredDtxConfig}`;
5693
- return sdp.replace(opusFmtp.original, newFmtp);
5533
+ const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, bitrate) => {
5534
+ // if the current resolution is lower than the target resolution,
5535
+ // we want to proportionally reduce the target bitrate
5536
+ const { width: targetWidth, height: targetHeight } = targetResolution;
5537
+ if (currentWidth < targetWidth || currentHeight < targetHeight) {
5538
+ const currentPixels = currentWidth * currentHeight;
5539
+ const targetPixels = targetWidth * targetHeight;
5540
+ const reductionFactor = currentPixels / targetPixels;
5541
+ return Math.round(bitrate * reductionFactor);
5542
+ }
5543
+ return bitrate;
5694
5544
  };
5695
5545
  /**
5696
- * Returns and SDP with all the codecs except the given codec removed.
5697
- */
5698
- const preserveCodec = (sdp, mid, codec) => {
5699
- const [kind, codecName] = codec.mimeType.toLowerCase().split('/');
5700
- const toSet = (fmtpLine) => new Set(fmtpLine.split(';').map((f) => f.trim().toLowerCase()));
5701
- const equal = (a, b) => {
5702
- if (a.size !== b.size)
5703
- return false;
5704
- for (const item of a)
5705
- if (!b.has(item))
5706
- return false;
5707
- return true;
5708
- };
5709
- const codecFmtp = toSet(codec.sdpFmtpLine || '');
5710
- const parsedSdp = SDP.parse(sdp);
5711
- for (const media of parsedSdp.media) {
5712
- if (media.type !== kind || String(media.mid) !== mid)
5713
- continue;
5714
- // find the payload id of the desired codec
5715
- const payloads = new Set();
5716
- for (const rtp of media.rtp) {
5717
- if (rtp.codec.toLowerCase() !== codecName)
5718
- continue;
5719
- const match =
5720
- // vp8 doesn't have any fmtp, we preserve it without any additional checks
5721
- codecName === 'vp8'
5722
- ? true
5723
- : media.fmtp.some((f) => f.payload === rtp.payload && equal(toSet(f.config), codecFmtp));
5724
- if (match) {
5725
- payloads.add(rtp.payload);
5726
- }
5727
- }
5728
- // find the corresponding rtx codec by matching apt=<preserved-codec-payload>
5729
- for (const fmtp of media.fmtp) {
5730
- const match = fmtp.config.match(/(apt)=(\d+)/);
5731
- if (!match)
5732
- continue;
5733
- const [, , preservedCodecPayload] = match;
5734
- if (payloads.has(Number(preservedCodecPayload))) {
5735
- payloads.add(fmtp.payload);
5736
- }
5737
- }
5738
- media.rtp = media.rtp.filter((r) => payloads.has(r.payload));
5739
- media.fmtp = media.fmtp.filter((f) => payloads.has(f.payload));
5740
- media.rtcpFb = media.rtcpFb?.filter((f) => payloads.has(f.payload));
5741
- media.payloads = Array.from(payloads).join(' ');
5742
- }
5743
- return SDP.write(parsedSdp);
5744
- };
5745
- /**
5746
- * Enables high-quality audio through SDP munging for the given trackMid.
5546
+ * Browsers have different simulcast constraints for different video resolutions.
5747
5547
  *
5748
- * @param sdp the SDP to munge.
5749
- * @param trackMid the trackMid.
5750
- * @param maxBitrate the max bitrate to set.
5751
- */
5752
- const enableHighQualityAudio = (sdp, trackMid, maxBitrate = 510000) => {
5753
- maxBitrate = Math.max(Math.min(maxBitrate, 510000), 96000);
5754
- const parsedSdp = SDP.parse(sdp);
5755
- const audioMedia = parsedSdp.media.find((m) => m.type === 'audio' && String(m.mid) === trackMid);
5756
- if (!audioMedia)
5757
- return sdp;
5758
- const opusRtp = audioMedia.rtp.find((r) => r.codec === 'opus');
5759
- if (!opusRtp)
5760
- return sdp;
5761
- const opusFmtp = audioMedia.fmtp.find((f) => f.payload === opusRtp.payload);
5762
- if (!opusFmtp)
5763
- return sdp;
5764
- // enable stereo, if not already enabled
5765
- if (opusFmtp.config.match(/stereo=(\d)/)) {
5766
- opusFmtp.config = opusFmtp.config.replace(/stereo=(\d)/, 'stereo=1');
5767
- }
5768
- else {
5769
- opusFmtp.config = `${opusFmtp.config};stereo=1`;
5548
+ * This function modifies the provided list of video layers according to the
5549
+ * current implementation of simulcast constraints in the Chromium based browsers.
5550
+ *
5551
+ * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
5552
+ */
5553
+ const withSimulcastConstraints = (settings, optimalVideoLayers) => {
5554
+ let layers;
5555
+ const size = Math.max(settings.width || 0, settings.height || 0);
5556
+ if (size <= 320) {
5557
+ // provide only one layer 320x240 (q), the one with the highest quality
5558
+ layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
5770
5559
  }
5771
- // set maxaveragebitrate, to the given value
5772
- if (opusFmtp.config.match(/maxaveragebitrate=(\d*)/)) {
5773
- opusFmtp.config = opusFmtp.config.replace(/maxaveragebitrate=(\d*)/, `maxaveragebitrate=${maxBitrate}`);
5560
+ else if (size <= 640) {
5561
+ // provide two layers, 160x120 (q) and 640x480 (h)
5562
+ layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
5774
5563
  }
5775
5564
  else {
5776
- opusFmtp.config = `${opusFmtp.config};maxaveragebitrate=${maxBitrate}`;
5565
+ // provide three layers for sizes > 640x480
5566
+ layers = optimalVideoLayers;
5777
5567
  }
5778
- return SDP.write(parsedSdp);
5568
+ const ridMapping = ['q', 'h', 'f'];
5569
+ return layers.map((layer, index) => ({
5570
+ ...layer,
5571
+ rid: ridMapping[index], // reassign rid
5572
+ }));
5779
5573
  };
5574
+
5780
5575
  /**
5781
5576
  * Extracts the mid from the transceiver or the SDP.
5782
5577
  *
@@ -5788,9 +5583,9 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5788
5583
  if (transceiver.mid)
5789
5584
  return transceiver.mid;
5790
5585
  if (!sdp)
5791
- return '';
5586
+ return String(transceiverInitIndex);
5792
5587
  const track = transceiver.sender.track;
5793
- const parsedSdp = SDP.parse(sdp);
5588
+ const parsedSdp = parse(sdp);
5794
5589
  const media = parsedSdp.media.find((m) => {
5795
5590
  return (m.type === track.kind &&
5796
5591
  // if `msid` is not present, we assume that the track is the first one
@@ -5798,7 +5593,7 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5798
5593
  });
5799
5594
  if (typeof media?.mid !== 'undefined')
5800
5595
  return String(media.mid);
5801
- if (transceiverInitIndex === -1)
5596
+ if (transceiverInitIndex < 0)
5802
5597
  return '';
5803
5598
  return String(transceiverInitIndex);
5804
5599
  };
@@ -5808,164 +5603,87 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5808
5603
  *
5809
5604
  * @internal
5810
5605
  */
5811
- class Publisher {
5606
+ class Publisher extends BasePeerConnection {
5812
5607
  /**
5813
5608
  * Constructs a new `Publisher` instance.
5814
5609
  */
5815
- constructor({ connectionConfig, sfuClient, dispatcher, state, isDtxEnabled, isRedEnabled, onUnrecoverableError, logTag, }) {
5816
- this.transceiverCache = new Map();
5817
- this.trackLayersCache = new Map();
5818
- this.publishOptsForTrack = new Map();
5819
- /**
5820
- * An array maintaining the order how transceivers were added to the peer connection.
5821
- * This is needed because some browsers (Firefox) don't reliably report
5822
- * trackId and `mid` parameters.
5823
- *
5824
- * @internal
5825
- */
5826
- this.transceiverInitOrder = [];
5827
- this.isIceRestarting = false;
5828
- this.createPeerConnection = (connectionConfig) => {
5829
- const pc = new RTCPeerConnection(connectionConfig);
5830
- pc.addEventListener('icecandidate', this.onIceCandidate);
5831
- pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5832
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5833
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5834
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5835
- pc.addEventListener('signalingstatechange', this.onSignalingStateChange);
5836
- return pc;
5837
- };
5838
- /**
5839
- * Closes the publisher PeerConnection and cleans up the resources.
5840
- */
5841
- this.close = ({ stopTracks }) => {
5842
- if (stopTracks) {
5843
- this.stopPublishing();
5844
- this.transceiverCache.clear();
5845
- this.trackLayersCache.clear();
5846
- }
5847
- this.detachEventHandlers();
5848
- this.pc.close();
5849
- };
5850
- /**
5851
- * Detaches the event handlers from the `RTCPeerConnection`.
5852
- * This is useful when we want to replace the `RTCPeerConnection`
5853
- * instance with a new one (in case of migration).
5854
- */
5855
- this.detachEventHandlers = () => {
5856
- this.unsubscribeOnIceRestart();
5857
- this.unsubscribeChangePublishQuality();
5858
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5859
- this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5860
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5861
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5862
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5863
- this.pc.removeEventListener('signalingstatechange', this.onSignalingStateChange);
5864
- };
5610
+ constructor({ publishOptions, ...baseOptions }) {
5611
+ super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
5612
+ this.transceiverCache = new TransceiverCache();
5865
5613
  /**
5866
5614
  * Starts publishing the given track of the given media stream.
5867
5615
  *
5868
5616
  * Consecutive calls to this method will replace the stream.
5869
5617
  * The previous stream will be stopped.
5870
5618
  *
5871
- * @param mediaStream the media stream to publish.
5872
5619
  * @param track the track to publish.
5873
5620
  * @param trackType the track type to publish.
5874
- * @param opts the optional publish options to use.
5875
5621
  */
5876
- this.publishStream = async (mediaStream, track, trackType, opts = {}) => {
5877
- if (track.readyState === 'ended') {
5878
- throw new Error(`Can't publish a track that has ended already.`);
5879
- }
5880
- // enable the track if it is disabled
5881
- if (!track.enabled)
5882
- track.enabled = true;
5883
- const transceiver = this.transceiverCache.get(trackType);
5884
- if (!transceiver || !transceiver.sender.track) {
5885
- // listen for 'ended' event on the track as it might be ended abruptly
5886
- // by an external factors such as permission revokes, a disconnected device, etc.
5887
- // keep in mind that `track.stop()` doesn't trigger this event.
5888
- const handleTrackEnded = () => {
5889
- this.logger('info', `Track ${TrackType[trackType]} has ended abruptly`);
5890
- track.removeEventListener('ended', handleTrackEnded);
5891
- this.notifyTrackMuteStateChanged(mediaStream, trackType, true).catch((err) => this.logger('warn', `Couldn't notify track mute state`, err));
5892
- };
5893
- track.addEventListener('ended', handleTrackEnded);
5894
- this.addTransceiver(trackType, track, opts, mediaStream);
5622
+ this.publish = async (track, trackType) => {
5623
+ if (!this.publishOptions.some((o) => o.trackType === trackType)) {
5624
+ throw new Error(`No publish options found for ${TrackType[trackType]}`);
5895
5625
  }
5896
- else {
5897
- await this.updateTransceiver(transceiver, track);
5626
+ for (const publishOption of this.publishOptions) {
5627
+ if (publishOption.trackType !== trackType)
5628
+ continue;
5629
+ // create a clone of the track as otherwise the same trackId will
5630
+ // appear in the SDP in multiple transceivers
5631
+ const trackToPublish = track.clone();
5632
+ const transceiver = this.transceiverCache.get(publishOption);
5633
+ if (!transceiver) {
5634
+ this.addTransceiver(trackToPublish, publishOption);
5635
+ }
5636
+ else {
5637
+ await transceiver.sender.replaceTrack(trackToPublish);
5638
+ }
5898
5639
  }
5899
- await this.notifyTrackMuteStateChanged(mediaStream, trackType, false);
5900
5640
  };
5901
5641
  /**
5902
- * Adds a new transceiver to the peer connection.
5903
- * This needs to be called when a new track kind is added to the peer connection.
5904
- * In other cases, use `updateTransceiver` method.
5642
+ * Adds a new transceiver carrying the given track to the peer connection.
5905
5643
  */
5906
- this.addTransceiver = (trackType, track, opts, mediaStream) => {
5907
- const { forceCodec, preferredCodec } = opts;
5908
- const codecInUse = forceCodec || getOptimalVideoCodec(preferredCodec);
5909
- const videoEncodings = this.computeLayers(trackType, track, opts);
5644
+ this.addTransceiver = (track, publishOption) => {
5645
+ const videoEncodings = computeVideoLayers(track, publishOption);
5646
+ const sendEncodings = isSvcCodec(publishOption.codec?.name)
5647
+ ? toSvcEncodings(videoEncodings)
5648
+ : videoEncodings;
5910
5649
  const transceiver = this.pc.addTransceiver(track, {
5911
5650
  direction: 'sendonly',
5912
- streams: trackType === TrackType.VIDEO || trackType === TrackType.SCREEN_SHARE
5913
- ? [mediaStream]
5914
- : undefined,
5915
- sendEncodings: isSvcCodec(codecInUse)
5916
- ? toSvcEncodings(videoEncodings)
5917
- : videoEncodings,
5651
+ sendEncodings,
5918
5652
  });
5653
+ const trackType = publishOption.trackType;
5919
5654
  this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
5920
- this.transceiverInitOrder.push(trackType);
5921
- this.transceiverCache.set(trackType, transceiver);
5922
- this.publishOptsForTrack.set(trackType, opts);
5923
- // handle codec preferences
5924
- if (!('setCodecPreferences' in transceiver))
5925
- return;
5926
- const codecPreferences = this.getCodecPreferences(trackType, trackType === TrackType.VIDEO ? codecInUse : undefined, 'receiver');
5927
- if (!codecPreferences)
5928
- return;
5929
- try {
5930
- this.logger('info', `Setting ${TrackType[trackType]} codec preferences`, codecPreferences);
5931
- transceiver.setCodecPreferences(codecPreferences);
5932
- }
5933
- catch (err) {
5934
- this.logger('warn', `Couldn't set codec preferences`, err);
5935
- }
5936
- };
5937
- /**
5938
- * Updates the given transceiver with the new track.
5939
- * Stops the previous track and replaces it with the new one.
5940
- */
5941
- this.updateTransceiver = async (transceiver, track) => {
5942
- const previousTrack = transceiver.sender.track;
5943
- // don't stop the track if we are re-publishing the same track
5944
- if (previousTrack && previousTrack !== track) {
5945
- previousTrack.stop();
5946
- }
5947
- await transceiver.sender.replaceTrack(track);
5655
+ this.transceiverCache.add(publishOption, transceiver);
5948
5656
  };
5949
5657
  /**
5950
- * Stops publishing the given track type to the SFU, if it is currently being published.
5951
- * Underlying track will be stopped and removed from the publisher.
5952
- * @param trackType the track type to unpublish.
5953
- * @param stopTrack specifies whether track should be stopped or just disabled
5658
+ * Synchronizes the current Publisher state with the provided publish options.
5954
5659
  */
5955
- this.unpublishStream = async (trackType, stopTrack) => {
5956
- const transceiver = this.transceiverCache.get(trackType);
5957
- if (transceiver &&
5958
- transceiver.sender.track &&
5959
- (stopTrack
5960
- ? transceiver.sender.track.readyState === 'live'
5961
- : transceiver.sender.track.enabled)) {
5962
- stopTrack
5963
- ? transceiver.sender.track.stop()
5964
- : (transceiver.sender.track.enabled = false);
5965
- // We don't need to notify SFU if unpublishing in response to remote soft mute
5966
- if (this.state.localParticipant?.publishedTracks.includes(trackType)) {
5967
- await this.notifyTrackMuteStateChanged(undefined, trackType, true);
5968
- }
5660
+ this.syncPublishOptions = async () => {
5661
+ // enable publishing with new options -> [av1, vp9]
5662
+ for (const publishOption of this.publishOptions) {
5663
+ const { trackType } = publishOption;
5664
+ if (!this.isPublishing(trackType))
5665
+ continue;
5666
+ if (this.transceiverCache.has(publishOption))
5667
+ continue;
5668
+ const item = this.transceiverCache.find((i) => !!i.transceiver.sender.track &&
5669
+ i.publishOption.trackType === trackType);
5670
+ if (!item || !item.transceiver)
5671
+ continue;
5672
+ // take the track from the existing transceiver for the same track type,
5673
+ // clone it and publish it with the new publish options
5674
+ const track = item.transceiver.sender.track.clone();
5675
+ this.addTransceiver(track, publishOption);
5676
+ }
5677
+ // stop publishing with options not required anymore -> [vp9]
5678
+ for (const item of this.transceiverCache.items()) {
5679
+ const { publishOption, transceiver } = item;
5680
+ const hasPublishOption = this.publishOptions.some((option) => option.id === publishOption.id &&
5681
+ option.trackType === publishOption.trackType);
5682
+ if (hasPublishOption)
5683
+ continue;
5684
+ // it is safe to stop the track here, it is a clone
5685
+ transceiver.sender.track?.stop();
5686
+ await transceiver.sender.replaceTrack(null);
5969
5687
  }
5970
5688
  };
5971
5689
  /**
@@ -5974,57 +5692,52 @@ class Publisher {
5974
5692
  * @param trackType the track type to check.
5975
5693
  */
5976
5694
  this.isPublishing = (trackType) => {
5977
- const transceiver = this.transceiverCache.get(trackType);
5978
- if (!transceiver || !transceiver.sender)
5979
- return false;
5980
- const track = transceiver.sender.track;
5981
- return !!track && track.readyState === 'live' && track.enabled;
5982
- };
5983
- this.notifyTrackMuteStateChanged = async (mediaStream, trackType, isMuted) => {
5984
- await this.sfuClient.updateMuteState(trackType, isMuted);
5985
- const audioOrVideoOrScreenShareStream = trackTypeToParticipantStreamKey(trackType);
5986
- if (!audioOrVideoOrScreenShareStream)
5987
- return;
5988
- if (isMuted) {
5989
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => ({
5990
- publishedTracks: p.publishedTracks.filter((t) => t !== trackType),
5991
- [audioOrVideoOrScreenShareStream]: undefined,
5992
- }));
5993
- }
5994
- else {
5995
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => {
5996
- return {
5997
- publishedTracks: p.publishedTracks.includes(trackType)
5998
- ? p.publishedTracks
5999
- : [...p.publishedTracks, trackType],
6000
- [audioOrVideoOrScreenShareStream]: mediaStream,
6001
- };
6002
- });
5695
+ for (const item of this.transceiverCache.items()) {
5696
+ if (item.publishOption.trackType !== trackType)
5697
+ continue;
5698
+ const track = item.transceiver.sender.track;
5699
+ if (!track)
5700
+ continue;
5701
+ if (track.readyState === 'live' && track.enabled)
5702
+ return true;
6003
5703
  }
5704
+ return false;
6004
5705
  };
6005
5706
  /**
6006
- * Stops publishing all tracks and stop all tracks.
5707
+ * Maps the given track ID to the corresponding track type.
6007
5708
  */
6008
- this.stopPublishing = () => {
6009
- this.logger('debug', 'Stopping publishing all tracks');
6010
- this.pc.getSenders().forEach((s) => {
6011
- s.track?.stop();
6012
- if (this.pc.signalingState !== 'closed') {
6013
- this.pc.removeTrack(s);
5709
+ this.getTrackType = (trackId) => {
5710
+ for (const transceiverId of this.transceiverCache.items()) {
5711
+ const { publishOption, transceiver } = transceiverId;
5712
+ if (transceiver.sender.track?.id === trackId) {
5713
+ return publishOption.trackType;
6014
5714
  }
6015
- });
5715
+ }
5716
+ return undefined;
6016
5717
  };
6017
- this.changePublishQuality = async (enabledLayers) => {
6018
- this.logger('info', 'Update publish quality, requested layers by SFU:', enabledLayers);
6019
- const videoSender = this.transceiverCache.get(TrackType.VIDEO)?.sender;
6020
- if (!videoSender) {
6021
- this.logger('warn', 'Update publish quality, no video sender found.');
6022
- return;
5718
+ /**
5719
+ * Stops the cloned track that is being published to the SFU.
5720
+ */
5721
+ this.stopTracks = (...trackTypes) => {
5722
+ for (const item of this.transceiverCache.items()) {
5723
+ const { publishOption, transceiver } = item;
5724
+ if (!trackTypes.includes(publishOption.trackType))
5725
+ continue;
5726
+ transceiver.sender.track?.stop();
6023
5727
  }
6024
- const params = videoSender.getParameters();
5728
+ };
5729
+ this.changePublishQuality = async (videoSender) => {
5730
+ const { trackType, layers, publishOptionId } = videoSender;
5731
+ const enabledLayers = layers.filter((l) => l.active);
5732
+ const tag = 'Update publish quality:';
5733
+ this.logger('info', `${tag} requested layers by SFU:`, enabledLayers);
5734
+ const sender = this.transceiverCache.getWith(trackType, publishOptionId)?.sender;
5735
+ if (!sender) {
5736
+ return this.logger('warn', `${tag} no video sender found.`);
5737
+ }
5738
+ const params = sender.getParameters();
6025
5739
  if (params.encodings.length === 0) {
6026
- this.logger('warn', 'Update publish quality, No suitable video encoding quality found');
6027
- return;
5740
+ return this.logger('warn', `${tag} there are no encodings set.`);
6028
5741
  }
6029
5742
  const [codecInUse] = params.codecs;
6030
5743
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse.mimeType);
@@ -6066,54 +5779,12 @@ class Publisher {
6066
5779
  changed = true;
6067
5780
  }
6068
5781
  }
6069
- const activeLayers = params.encodings.filter((e) => e.active);
5782
+ const activeEncoders = params.encodings.filter((e) => e.active);
6070
5783
  if (!changed) {
6071
- this.logger('info', `Update publish quality, no change:`, activeLayers);
6072
- return;
6073
- }
6074
- await videoSender.setParameters(params);
6075
- this.logger('info', `Update publish quality, enabled rids:`, activeLayers);
6076
- };
6077
- /**
6078
- * Returns the result of the `RTCPeerConnection.getStats()` method
6079
- * @param selector
6080
- * @returns
6081
- */
6082
- this.getStats = (selector) => {
6083
- return this.pc.getStats(selector);
6084
- };
6085
- this.getCodecPreferences = (trackType, preferredCodec, codecPreferencesSource) => {
6086
- if (trackType === TrackType.VIDEO) {
6087
- return getPreferredCodecs('video', preferredCodec || 'vp8', undefined, codecPreferencesSource);
6088
- }
6089
- if (trackType === TrackType.AUDIO) {
6090
- const defaultAudioCodec = this.isRedEnabled ? 'red' : 'opus';
6091
- const codecToRemove = !this.isRedEnabled ? 'red' : undefined;
6092
- return getPreferredCodecs('audio', preferredCodec ?? defaultAudioCodec, codecToRemove, codecPreferencesSource);
6093
- }
6094
- };
6095
- this.onIceCandidate = (e) => {
6096
- const { candidate } = e;
6097
- if (!candidate) {
6098
- this.logger('debug', 'null ice candidate');
6099
- return;
5784
+ return this.logger('info', `${tag} no change:`, activeEncoders);
6100
5785
  }
6101
- this.sfuClient
6102
- .iceTrickle({
6103
- iceCandidate: getIceCandidate(candidate),
6104
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
6105
- })
6106
- .catch((err) => {
6107
- this.logger('warn', `ICETrickle failed`, err);
6108
- });
6109
- };
6110
- /**
6111
- * Sets the SFU client to use.
6112
- *
6113
- * @param sfuClient the SFU client to use.
6114
- */
6115
- this.setSfuClient = (sfuClient) => {
6116
- this.sfuClient = sfuClient;
5786
+ await sender.setParameters(params);
5787
+ this.logger('info', `${tag} enabled rids:`, activeEncoders);
6117
5788
  };
6118
5789
  /**
6119
5790
  * Restarts the ICE connection and renegotiates with the SFU.
@@ -6128,7 +5799,7 @@ class Publisher {
6128
5799
  await this.negotiate({ iceRestart: true });
6129
5800
  };
6130
5801
  this.onNegotiationNeeded = () => {
6131
- this.negotiate().catch((err) => {
5802
+ withoutConcurrency('publisher.negotiate', () => this.negotiate()).catch((err) => {
6132
5803
  this.logger('error', `Negotiation failed.`, err);
6133
5804
  this.onUnrecoverableError?.();
6134
5805
  });
@@ -6140,18 +5811,6 @@ class Publisher {
6140
5811
  */
6141
5812
  this.negotiate = async (options) => {
6142
5813
  const offer = await this.pc.createOffer(options);
6143
- if (offer.sdp) {
6144
- offer.sdp = toggleDtx(offer.sdp, this.isDtxEnabled);
6145
- if (this.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
6146
- offer.sdp = this.enableHighQualityAudio(offer.sdp);
6147
- }
6148
- if (this.isPublishing(TrackType.VIDEO)) {
6149
- // Hotfix for platforms that don't respect the ordered codec list
6150
- // (Firefox, Android, Linux, etc...).
6151
- // We remove all the codecs from the SDP except the one we want to use.
6152
- offer.sdp = this.removeUnpreferredCodecs(offer.sdp, TrackType.VIDEO);
6153
- }
6154
- }
6155
5814
  const trackInfos = this.getAnnouncedTracks(offer.sdp);
6156
5815
  if (trackInfos.length === 0) {
6157
5816
  throw new Error(`Can't negotiate without announcing any tracks`);
@@ -6170,238 +5829,121 @@ class Publisher {
6170
5829
  finally {
6171
5830
  this.isIceRestarting = false;
6172
5831
  }
6173
- this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(async (candidate) => {
6174
- try {
6175
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6176
- await this.pc.addIceCandidate(iceCandidate);
6177
- }
6178
- catch (e) {
6179
- this.logger('warn', `ICE candidate error`, e, candidate);
6180
- }
6181
- });
6182
- };
6183
- this.enableHighQualityAudio = (sdp) => {
6184
- const transceiver = this.transceiverCache.get(TrackType.SCREEN_SHARE_AUDIO);
6185
- if (!transceiver)
6186
- return sdp;
6187
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(TrackType.SCREEN_SHARE_AUDIO);
6188
- const mid = extractMid(transceiver, transceiverInitIndex, sdp);
6189
- return enableHighQualityAudio(sdp, mid);
5832
+ this.addTrickledIceCandidates();
6190
5833
  };
6191
5834
  /**
6192
5835
  * Returns a list of tracks that are currently being published.
6193
- *
6194
- * @internal
6195
- * @param sdp an optional SDP to extract the `mid` from.
6196
5836
  */
6197
- this.getAnnouncedTracks = (sdp) => {
6198
- sdp = sdp || this.pc.localDescription?.sdp;
6199
- return this.pc
6200
- .getTransceivers()
6201
- .filter((t) => t.direction === 'sendonly' && t.sender.track)
6202
- .map((transceiver) => {
6203
- let trackType;
6204
- this.transceiverCache.forEach((value, key) => {
6205
- if (value === transceiver)
6206
- trackType = key;
6207
- });
5837
+ this.getPublishedTracks = () => {
5838
+ const tracks = [];
5839
+ for (const { transceiver } of this.transceiverCache.items()) {
6208
5840
  const track = transceiver.sender.track;
6209
- let optimalLayers;
6210
- const isTrackLive = track.readyState === 'live';
6211
- if (isTrackLive) {
6212
- optimalLayers = this.computeLayers(trackType, track) || [];
6213
- this.trackLayersCache.set(trackType, optimalLayers);
6214
- }
6215
- else {
6216
- // we report the last known optimal layers for ended tracks
6217
- optimalLayers = this.trackLayersCache.get(trackType) || [];
6218
- this.logger('debug', `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`, optimalLayers);
6219
- }
6220
- const layers = optimalLayers.map((optimalLayer) => ({
6221
- rid: optimalLayer.rid || '',
6222
- bitrate: optimalLayer.maxBitrate || 0,
6223
- fps: optimalLayer.maxFramerate || 0,
6224
- quality: ridToVideoQuality(optimalLayer.rid || ''),
6225
- videoDimension: {
6226
- width: optimalLayer.width,
6227
- height: optimalLayer.height,
6228
- },
6229
- }));
6230
- const isAudioTrack = [
6231
- TrackType.AUDIO,
6232
- TrackType.SCREEN_SHARE_AUDIO,
6233
- ].includes(trackType);
6234
- const trackSettings = track.getSettings();
6235
- const isStereo = isAudioTrack && trackSettings.channelCount === 2;
6236
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(trackType);
6237
- return {
6238
- trackId: track.id,
6239
- layers: layers,
6240
- trackType,
6241
- mid: extractMid(transceiver, transceiverInitIndex, sdp),
6242
- stereo: isStereo,
6243
- dtx: isAudioTrack && this.isDtxEnabled,
6244
- red: isAudioTrack && this.isRedEnabled,
6245
- muted: !isTrackLive,
6246
- };
6247
- });
6248
- };
6249
- this.computeLayers = (trackType, track, opts) => {
6250
- const { settings } = this.state;
6251
- const targetResolution = settings?.video
6252
- .target_resolution;
6253
- const screenShareBitrate = settings?.screensharing.target_resolution?.bitrate;
6254
- const publishOpts = opts || this.publishOptsForTrack.get(trackType);
6255
- const codecInUse = opts?.forceCodec || getOptimalVideoCodec(opts?.preferredCodec);
6256
- return trackType === TrackType.VIDEO
6257
- ? findOptimalVideoLayers(track, targetResolution, codecInUse, publishOpts)
6258
- : trackType === TrackType.SCREEN_SHARE
6259
- ? findOptimalScreenSharingLayers(track, publishOpts, screenShareBitrate)
6260
- : undefined;
6261
- };
6262
- this.onIceCandidateError = (e) => {
6263
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6264
- `${e.errorCode}: ${e.errorText}`;
6265
- const iceState = this.pc.iceConnectionState;
6266
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6267
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6268
- };
6269
- this.onIceConnectionStateChange = () => {
6270
- const state = this.pc.iceConnectionState;
6271
- this.logger('debug', `ICE Connection state changed to`, state);
6272
- if (this.state.callingState === CallingState.RECONNECTING)
6273
- return;
6274
- if (state === 'failed' || state === 'disconnected') {
6275
- this.logger('debug', `Attempting to restart ICE`);
6276
- this.restartIce().catch((e) => {
6277
- this.logger('error', `ICE restart error`, e);
6278
- this.onUnrecoverableError?.();
6279
- });
5841
+ if (track && track.readyState === 'live')
5842
+ tracks.push(track);
6280
5843
  }
6281
- };
6282
- this.onIceGatheringStateChange = () => {
6283
- this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
6284
- };
6285
- this.onSignalingStateChange = () => {
6286
- this.logger('debug', `Signaling state changed`, this.pc.signalingState);
6287
- };
6288
- this.logger = getLogger(['Publisher', logTag]);
6289
- this.pc = this.createPeerConnection(connectionConfig);
6290
- this.sfuClient = sfuClient;
6291
- this.state = state;
6292
- this.isDtxEnabled = isDtxEnabled;
6293
- this.isRedEnabled = isRedEnabled;
6294
- this.onUnrecoverableError = onUnrecoverableError;
6295
- this.unsubscribeOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6296
- if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
6297
- return;
6298
- this.restartIce().catch((err) => {
6299
- this.logger('warn', `ICERestart failed`, err);
6300
- this.onUnrecoverableError?.();
6301
- });
6302
- });
6303
- this.unsubscribeChangePublishQuality = dispatcher.on('changePublishQuality', ({ videoSenders }) => {
6304
- withoutConcurrency('publisher.changePublishQuality', async () => {
6305
- for (const videoSender of videoSenders) {
6306
- const { layers } = videoSender;
6307
- const enabledLayers = layers.filter((l) => l.active);
6308
- await this.changePublishQuality(enabledLayers);
6309
- }
6310
- }).catch((err) => {
6311
- this.logger('warn', 'Failed to change publish quality', err);
6312
- });
6313
- });
6314
- }
6315
- removeUnpreferredCodecs(sdp, trackType) {
6316
- const opts = this.publishOptsForTrack.get(trackType);
6317
- const forceSingleCodec = !!opts?.forceSingleCodec || isReactNative() || isFirefox();
6318
- if (!opts || !forceSingleCodec)
6319
- return sdp;
6320
- const codec = opts.forceCodec || getOptimalVideoCodec(opts.preferredCodec);
6321
- const orderedCodecs = this.getCodecPreferences(trackType, codec, 'sender');
6322
- if (!orderedCodecs || orderedCodecs.length === 0)
6323
- return sdp;
6324
- const transceiver = this.transceiverCache.get(trackType);
6325
- if (!transceiver)
6326
- return sdp;
6327
- const index = this.transceiverInitOrder.indexOf(trackType);
6328
- const mid = extractMid(transceiver, index, sdp);
6329
- const [codecToPreserve] = orderedCodecs;
6330
- return preserveCodec(sdp, mid, codecToPreserve);
6331
- }
6332
- }
6333
-
6334
- /**
6335
- * A wrapper around the `RTCPeerConnection` that handles the incoming
6336
- * media streams from the SFU.
6337
- *
6338
- * @internal
6339
- */
6340
- class Subscriber {
6341
- /**
6342
- * Constructs a new `Subscriber` instance.
6343
- *
6344
- * @param sfuClient the SFU client to use.
6345
- * @param dispatcher the dispatcher to use.
6346
- * @param state the state of the call.
6347
- * @param connectionConfig the connection configuration to use.
6348
- * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
6349
- * @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
6350
- * @param logTag a tag to use for logging.
6351
- */
6352
- constructor({ sfuClient, dispatcher, state, connectionConfig, onUnrecoverableError, logTag, }) {
6353
- this.isIceRestarting = false;
6354
- /**
6355
- * Creates a new `RTCPeerConnection` instance with the given configuration.
6356
- *
6357
- * @param connectionConfig the connection configuration to use.
6358
- */
6359
- this.createPeerConnection = (connectionConfig) => {
6360
- const pc = new RTCPeerConnection(connectionConfig);
6361
- pc.addEventListener('icecandidate', this.onIceCandidate);
6362
- pc.addEventListener('track', this.handleOnTrack);
6363
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6364
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6365
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
6366
- return pc;
6367
- };
6368
- /**
6369
- * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
6370
- */
6371
- this.close = () => {
6372
- this.detachEventHandlers();
6373
- this.pc.close();
5844
+ return tracks;
6374
5845
  };
6375
5846
  /**
6376
- * Detaches the event handlers from the `RTCPeerConnection`.
6377
- * This is useful when we want to replace the `RTCPeerConnection`
6378
- * instance with a new one (in case of migration).
5847
+ * Returns a list of tracks that are currently being published.
5848
+ * @param sdp an optional SDP to extract the `mid` from.
6379
5849
  */
6380
- this.detachEventHandlers = () => {
6381
- this.unregisterOnSubscriberOffer();
6382
- this.unregisterOnIceRestart();
6383
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
6384
- this.pc.removeEventListener('track', this.handleOnTrack);
6385
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6386
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6387
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5850
+ this.getAnnouncedTracks = (sdp) => {
5851
+ const trackInfos = [];
5852
+ for (const bundle of this.transceiverCache.items()) {
5853
+ const { transceiver, publishOption } = bundle;
5854
+ const track = transceiver.sender.track;
5855
+ if (!track)
5856
+ continue;
5857
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5858
+ }
5859
+ return trackInfos;
6388
5860
  };
6389
5861
  /**
6390
- * Returns the result of the `RTCPeerConnection.getStats()` method
6391
- * @param selector
6392
- * @returns
6393
- */
6394
- this.getStats = (selector) => {
6395
- return this.pc.getStats(selector);
5862
+ * Returns a list of tracks that are currently being published.
5863
+ * This method shall be used for the reconnection flow.
5864
+ * There we shouldn't announce the tracks that have been stopped due to a codec switch.
5865
+ */
5866
+ this.getAnnouncedTracksForReconnect = () => {
5867
+ const sdp = this.pc.localDescription?.sdp;
5868
+ const trackInfos = [];
5869
+ for (const publishOption of this.publishOptions) {
5870
+ const transceiver = this.transceiverCache.get(publishOption);
5871
+ if (!transceiver || !transceiver.sender.track)
5872
+ continue;
5873
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5874
+ }
5875
+ return trackInfos;
6396
5876
  };
6397
5877
  /**
6398
- * Sets the SFU client to use.
6399
- *
6400
- * @param sfuClient the SFU client to use.
5878
+ * Converts the given transceiver to a `TrackInfo` object.
6401
5879
  */
6402
- this.setSfuClient = (sfuClient) => {
6403
- this.sfuClient = sfuClient;
5880
+ this.toTrackInfo = (transceiver, publishOption, sdp) => {
5881
+ const track = transceiver.sender.track;
5882
+ const isTrackLive = track.readyState === 'live';
5883
+ const layers = isTrackLive
5884
+ ? computeVideoLayers(track, publishOption)
5885
+ : this.transceiverCache.getLayers(publishOption);
5886
+ this.transceiverCache.setLayers(publishOption, layers);
5887
+ const isAudioTrack = isAudioTrackType(publishOption.trackType);
5888
+ const isStereo = isAudioTrack && track.getSettings().channelCount === 2;
5889
+ const transceiverIndex = this.transceiverCache.indexOf(transceiver);
5890
+ const audioSettings = this.state.settings?.audio;
5891
+ return {
5892
+ trackId: track.id,
5893
+ layers: toVideoLayers(layers),
5894
+ trackType: publishOption.trackType,
5895
+ mid: extractMid(transceiver, transceiverIndex, sdp),
5896
+ stereo: isStereo,
5897
+ dtx: isAudioTrack && !!audioSettings?.opus_dtx_enabled,
5898
+ red: isAudioTrack && !!audioSettings?.redundant_coding_enabled,
5899
+ muted: !isTrackLive,
5900
+ codec: publishOption.codec,
5901
+ publishOptionId: publishOption.id,
5902
+ };
6404
5903
  };
5904
+ this.publishOptions = publishOptions;
5905
+ this.pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5906
+ this.on('iceRestart', (iceRestart) => {
5907
+ if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
5908
+ return;
5909
+ this.restartIce().catch((err) => {
5910
+ this.logger('warn', `ICERestart failed`, err);
5911
+ this.onUnrecoverableError?.();
5912
+ });
5913
+ });
5914
+ this.on('changePublishQuality', async (event) => {
5915
+ for (const videoSender of event.videoSenders) {
5916
+ await this.changePublishQuality(videoSender);
5917
+ }
5918
+ });
5919
+ this.on('changePublishOptions', (event) => {
5920
+ this.publishOptions = event.publishOptions;
5921
+ return this.syncPublishOptions();
5922
+ });
5923
+ }
5924
+ /**
5925
+ * Detaches the event handlers from the `RTCPeerConnection`.
5926
+ * This is useful when we want to replace the `RTCPeerConnection`
5927
+ * instance with a new one (in case of migration).
5928
+ */
5929
+ detachEventHandlers() {
5930
+ super.detachEventHandlers();
5931
+ this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5932
+ }
5933
+ }
5934
+
5935
+ /**
5936
+ * A wrapper around the `RTCPeerConnection` that handles the incoming
5937
+ * media streams from the SFU.
5938
+ *
5939
+ * @internal
5940
+ */
5941
+ class Subscriber extends BasePeerConnection {
5942
+ /**
5943
+ * Constructs a new `Subscriber` instance.
5944
+ */
5945
+ constructor(opts) {
5946
+ super(PeerType.SUBSCRIBER, opts);
6405
5947
  /**
6406
5948
  * Restarts the ICE connection and renegotiates with the SFU.
6407
5949
  */
@@ -6464,7 +6006,15 @@ class Subscriber {
6464
6006
  this.logger('error', `Unknown track type: ${rawTrackType}`);
6465
6007
  return;
6466
6008
  }
6009
+ // get the previous stream to dispose it later
6010
+ // usually this happens during migration, when the stream is replaced
6011
+ // with a new one but the old one is still in the state
6467
6012
  const previousStream = participantToUpdate[streamKindProp];
6013
+ // replace the previous stream with the new one, prevents flickering
6014
+ this.state.updateParticipant(participantToUpdate.sessionId, {
6015
+ [streamKindProp]: primaryStream,
6016
+ });
6017
+ // now, dispose the previous stream if it exists
6468
6018
  if (previousStream) {
6469
6019
  this.logger('info', `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
6470
6020
  previousStream.getTracks().forEach((t) => {
@@ -6472,24 +6022,6 @@ class Subscriber {
6472
6022
  previousStream.removeTrack(t);
6473
6023
  });
6474
6024
  }
6475
- this.state.updateParticipant(participantToUpdate.sessionId, {
6476
- [streamKindProp]: primaryStream,
6477
- });
6478
- };
6479
- this.onIceCandidate = (e) => {
6480
- const { candidate } = e;
6481
- if (!candidate) {
6482
- this.logger('debug', 'null ice candidate');
6483
- return;
6484
- }
6485
- this.sfuClient
6486
- .iceTrickle({
6487
- iceCandidate: getIceCandidate(candidate),
6488
- peerType: PeerType.SUBSCRIBER,
6489
- })
6490
- .catch((err) => {
6491
- this.logger('warn', `ICETrickle failed`, err);
6492
- });
6493
6025
  };
6494
6026
  this.negotiate = async (subscriberOffer) => {
6495
6027
  this.logger('info', `Received subscriberOffer`, subscriberOffer);
@@ -6497,15 +6029,7 @@ class Subscriber {
6497
6029
  type: 'offer',
6498
6030
  sdp: subscriberOffer.sdp,
6499
6031
  });
6500
- this.sfuClient.iceTrickleBuffer.subscriberCandidates.subscribe(async (candidate) => {
6501
- try {
6502
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6503
- await this.pc.addIceCandidate(iceCandidate);
6504
- }
6505
- catch (e) {
6506
- this.logger('warn', `ICE candidate error`, [e, candidate]);
6507
- }
6508
- });
6032
+ this.addTrickledIceCandidates();
6509
6033
  const answer = await this.pc.createAnswer();
6510
6034
  await this.pc.setLocalDescription(answer);
6511
6035
  await this.sfuClient.sendAnswer({
@@ -6514,56 +6038,21 @@ class Subscriber {
6514
6038
  });
6515
6039
  this.isIceRestarting = false;
6516
6040
  };
6517
- this.onIceConnectionStateChange = () => {
6518
- const state = this.pc.iceConnectionState;
6519
- this.logger('debug', `ICE connection state changed`, state);
6520
- if (this.state.callingState === CallingState.RECONNECTING)
6521
- return;
6522
- // do nothing when ICE is restarting
6523
- if (this.isIceRestarting)
6524
- return;
6525
- if (state === 'failed' || state === 'disconnected') {
6526
- this.logger('debug', `Attempting to restart ICE`);
6527
- this.restartIce().catch((e) => {
6528
- this.logger('error', `ICE restart failed`, e);
6529
- this.onUnrecoverableError?.();
6530
- });
6531
- }
6532
- };
6533
- this.onIceGatheringStateChange = () => {
6534
- this.logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
6535
- };
6536
- this.onIceCandidateError = (e) => {
6537
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6538
- `${e.errorCode}: ${e.errorText}`;
6539
- const iceState = this.pc.iceConnectionState;
6540
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6541
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6542
- };
6543
- this.logger = getLogger(['Subscriber', logTag]);
6544
- this.sfuClient = sfuClient;
6545
- this.state = state;
6546
- this.onUnrecoverableError = onUnrecoverableError;
6547
- this.pc = this.createPeerConnection(connectionConfig);
6548
- const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
6549
- this.unregisterOnSubscriberOffer = dispatcher.on('subscriberOffer', (subscriberOffer) => {
6550
- withoutConcurrency(subscriberOfferConcurrencyTag, () => {
6551
- return this.negotiate(subscriberOffer);
6552
- }).catch((err) => {
6041
+ this.pc.addEventListener('track', this.handleOnTrack);
6042
+ this.on('subscriberOffer', async (subscriberOffer) => {
6043
+ return this.negotiate(subscriberOffer).catch((err) => {
6553
6044
  this.logger('error', `Negotiation failed.`, err);
6554
6045
  });
6555
6046
  });
6556
- const iceRestartConcurrencyTag = Symbol('iceRestart');
6557
- this.unregisterOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6558
- withoutConcurrency(iceRestartConcurrencyTag, async () => {
6559
- if (iceRestart.peerType !== PeerType.SUBSCRIBER)
6560
- return;
6561
- await this.restartIce();
6562
- }).catch((err) => {
6563
- this.logger('error', `ICERestart failed`, err);
6564
- this.onUnrecoverableError?.();
6565
- });
6566
- });
6047
+ }
6048
+ /**
6049
+ * Detaches the event handlers from the `RTCPeerConnection`.
6050
+ * This is useful when we want to replace the `RTCPeerConnection`
6051
+ * instance with a new one (in case of migration).
6052
+ */
6053
+ detachEventHandlers() {
6054
+ super.detachEventHandlers();
6055
+ this.pc.removeEventListener('track', this.handleOnTrack);
6567
6056
  }
6568
6057
  }
6569
6058
 
@@ -6595,6 +6084,16 @@ const createWebSocketSignalChannel = (opts) => {
6595
6084
  return ws;
6596
6085
  };
6597
6086
 
6087
+ const toRtcConfiguration = (config) => {
6088
+ return {
6089
+ iceServers: config.map((ice) => ({
6090
+ urls: ice.urls,
6091
+ username: ice.username,
6092
+ credential: ice.password,
6093
+ })),
6094
+ };
6095
+ };
6096
+
6598
6097
  /**
6599
6098
  * Saving a long-lived reference to a promise that can reject can be unsafe,
6600
6099
  * since rejecting the promise causes an unhandled rejection error (even if the
@@ -6879,6 +6378,7 @@ class StreamSfuClient {
6879
6378
  clearTimeout(this.migrateAwayTimeout);
6880
6379
  this.abortController.abort();
6881
6380
  this.migrationTask?.resolve();
6381
+ this.iceTrickleBuffer.dispose();
6882
6382
  };
6883
6383
  this.leaveAndClose = async (reason) => {
6884
6384
  await this.joinTask;
@@ -6911,13 +6411,9 @@ class StreamSfuClient {
6911
6411
  await this.joinTask;
6912
6412
  return retryable(() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6913
6413
  };
6914
- this.updateMuteState = async (trackType, muted) => {
6915
- await this.joinTask;
6916
- return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
6917
- };
6918
- this.updateMuteStates = async (data) => {
6414
+ this.updateMuteStates = async (muteStates) => {
6919
6415
  await this.joinTask;
6920
- return retryable(() => this.rpc.updateMuteStates({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6416
+ return retryable(() => this.rpc.updateMuteStates({ muteStates, sessionId: this.sessionId }), this.abortController.signal);
6921
6417
  };
6922
6418
  this.sendStats = async (stats) => {
6923
6419
  await this.joinTask;
@@ -7097,16 +6593,6 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
7097
6593
  */
7098
6594
  StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
7099
6595
 
7100
- const toRtcConfiguration = (config) => {
7101
- return {
7102
- iceServers: config.map((ice) => ({
7103
- urls: ice.urls,
7104
- username: ice.username,
7105
- credential: ice.password,
7106
- })),
7107
- };
7108
- };
7109
-
7110
6596
  /**
7111
6597
  * Event handler that watched the delivery of `call.accepted`.
7112
6598
  * Once the event is received, the call is joined.
@@ -7325,6 +6811,21 @@ const handleRemoteSoftMute = (call) => {
7325
6811
  });
7326
6812
  };
7327
6813
 
6814
+ /**
6815
+ * Adds unique values to an array.
6816
+ *
6817
+ * @param arr the array to add to.
6818
+ * @param values the values to add.
6819
+ */
6820
+ const pushToIfMissing = (arr, ...values) => {
6821
+ for (const v of values) {
6822
+ if (!arr.includes(v)) {
6823
+ arr.push(v);
6824
+ }
6825
+ }
6826
+ return arr;
6827
+ };
6828
+
7328
6829
  /**
7329
6830
  * An event responder which handles the `participantJoined` event.
7330
6831
  */
@@ -7390,7 +6891,7 @@ const watchTrackPublished = (state) => {
7390
6891
  }
7391
6892
  else {
7392
6893
  state.updateParticipant(sessionId, (p) => ({
7393
- publishedTracks: [...p.publishedTracks, type].filter(unique),
6894
+ publishedTracks: pushToIfMissing([...p.publishedTracks], type),
7394
6895
  }));
7395
6896
  }
7396
6897
  };
@@ -7415,7 +6916,6 @@ const watchTrackUnpublished = (state) => {
7415
6916
  }
7416
6917
  };
7417
6918
  };
7418
- const unique = (v, i, arr) => arr.indexOf(v) === i;
7419
6919
  /**
7420
6920
  * Reconciles orphaned tracks (if any) for the given participant.
7421
6921
  *
@@ -7565,6 +7065,38 @@ const getSdkVersion = (sdk) => {
7565
7065
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7566
7066
  };
7567
7067
 
7068
+ /**
7069
+ * Checks whether the current browser is Safari.
7070
+ */
7071
+ const isSafari = () => {
7072
+ if (typeof navigator === 'undefined')
7073
+ return false;
7074
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
7075
+ };
7076
+ /**
7077
+ * Checks whether the current browser is Firefox.
7078
+ */
7079
+ const isFirefox = () => {
7080
+ if (typeof navigator === 'undefined')
7081
+ return false;
7082
+ return navigator.userAgent?.includes('Firefox');
7083
+ };
7084
+ /**
7085
+ * Checks whether the current browser is Google Chrome.
7086
+ */
7087
+ const isChrome = () => {
7088
+ if (typeof navigator === 'undefined')
7089
+ return false;
7090
+ return navigator.userAgent?.includes('Chrome');
7091
+ };
7092
+
7093
+ var browsers = /*#__PURE__*/Object.freeze({
7094
+ __proto__: null,
7095
+ isChrome: isChrome,
7096
+ isFirefox: isFirefox,
7097
+ isSafari: isSafari
7098
+ });
7099
+
7568
7100
  /**
7569
7101
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
7570
7102
  */
@@ -7581,12 +7113,12 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7581
7113
  return undefined;
7582
7114
  }
7583
7115
  };
7584
- const getStatsForStream = async (kind, mediaStream) => {
7116
+ const getStatsForStream = async (kind, tracks) => {
7585
7117
  const pc = kind === 'subscriber' ? subscriber : publisher;
7586
7118
  if (!pc)
7587
7119
  return [];
7588
7120
  const statsForStream = [];
7589
- for (let track of mediaStream.getTracks()) {
7121
+ for (const track of tracks) {
7590
7122
  const report = await pc.getStats(track);
7591
7123
  const stats = transform(report, {
7592
7124
  // @ts-ignore
@@ -7611,26 +7143,24 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7611
7143
  */
7612
7144
  const run = async () => {
7613
7145
  const participantStats = {};
7614
- const sessionIds = new Set(sessionIdsToTrack);
7615
- if (sessionIds.size > 0) {
7616
- for (let participant of state.participants) {
7146
+ if (sessionIdsToTrack.size > 0) {
7147
+ const sessionIds = new Set(sessionIdsToTrack);
7148
+ for (const participant of state.participants) {
7617
7149
  if (!sessionIds.has(participant.sessionId))
7618
7150
  continue;
7619
- const kind = participant.isLocalParticipant
7620
- ? 'publisher'
7621
- : 'subscriber';
7151
+ const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant;
7152
+ const kind = isLocalParticipant ? 'publisher' : 'subscriber';
7622
7153
  try {
7623
- const mergedStream = new MediaStream([
7624
- ...(participant.videoStream?.getVideoTracks() || []),
7625
- ...(participant.audioStream?.getAudioTracks() || []),
7626
- ]);
7627
- participantStats[participant.sessionId] = await getStatsForStream(kind, mergedStream);
7628
- mergedStream.getTracks().forEach((t) => {
7629
- mergedStream.removeTrack(t);
7630
- });
7154
+ const tracks = isLocalParticipant
7155
+ ? publisher?.getPublishedTracks() || []
7156
+ : [
7157
+ ...(videoStream?.getVideoTracks() || []),
7158
+ ...(audioStream?.getAudioTracks() || []),
7159
+ ];
7160
+ participantStats[sessionId] = await getStatsForStream(kind, tracks);
7631
7161
  }
7632
7162
  catch (e) {
7633
- logger('error', `Failed to collect stats for ${kind} of ${participant.userId}`, e);
7163
+ logger('warn', `Failed to collect ${kind} stats for ${userId}`, e);
7634
7164
  }
7635
7165
  }
7636
7166
  }
@@ -7640,6 +7170,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7640
7170
  .then((report) => transform(report, {
7641
7171
  kind: 'subscriber',
7642
7172
  trackKind: 'video',
7173
+ publisher,
7643
7174
  }))
7644
7175
  .then(aggregate),
7645
7176
  publisher
@@ -7648,6 +7179,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7648
7179
  .then((report) => transform(report, {
7649
7180
  kind: 'publisher',
7650
7181
  trackKind: 'video',
7182
+ publisher,
7651
7183
  }))
7652
7184
  .then(aggregate)
7653
7185
  : getEmptyStats(),
@@ -7696,7 +7228,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7696
7228
  * @param opts the transform options.
7697
7229
  */
7698
7230
  const transform = (report, opts) => {
7699
- const { trackKind, kind } = opts;
7231
+ const { trackKind, kind, publisher } = opts;
7700
7232
  const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
7701
7233
  const stats = flatten(report);
7702
7234
  const streams = stats
@@ -7712,6 +7244,16 @@ const transform = (report, opts) => {
7712
7244
  s.id === transport.selectedCandidatePairId);
7713
7245
  roundTripTime = candidatePair?.currentRoundTripTime;
7714
7246
  }
7247
+ let trackType;
7248
+ if (kind === 'publisher' && publisher) {
7249
+ const firefox = isFirefox();
7250
+ const mediaSource = stats.find((s) => s.type === 'media-source' &&
7251
+ // Firefox doesn't have mediaSourceId, so we need to guess the media source
7252
+ (firefox ? true : s.id === rtcStreamStats.mediaSourceId));
7253
+ if (mediaSource) {
7254
+ trackType = publisher.getTrackType(mediaSource.trackIdentifier);
7255
+ }
7256
+ }
7715
7257
  return {
7716
7258
  bytesSent: rtcStreamStats.bytesSent,
7717
7259
  bytesReceived: rtcStreamStats.bytesReceived,
@@ -7722,10 +7264,12 @@ const transform = (report, opts) => {
7722
7264
  framesPerSecond: rtcStreamStats.framesPerSecond,
7723
7265
  jitter: rtcStreamStats.jitter,
7724
7266
  kind: rtcStreamStats.kind,
7267
+ mediaSourceId: rtcStreamStats.mediaSourceId,
7725
7268
  // @ts-ignore: available in Chrome only, TS doesn't recognize this
7726
7269
  qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
7727
7270
  rid: rtcStreamStats.rid,
7728
7271
  ssrc: rtcStreamStats.ssrc,
7272
+ trackType,
7729
7273
  };
7730
7274
  });
7731
7275
  return {
@@ -7746,6 +7290,7 @@ const getEmptyStats = (stats) => {
7746
7290
  highestFrameHeight: 0,
7747
7291
  highestFramesPerSecond: 0,
7748
7292
  codec: '',
7293
+ codecPerTrackType: {},
7749
7294
  timestamp: Date.now(),
7750
7295
  };
7751
7296
  };
@@ -7781,18 +7326,152 @@ const aggregate = (stats) => {
7781
7326
  report.averageRoundTripTimeInMs = Math.round((report.averageRoundTripTimeInMs / streams.length) * 1000);
7782
7327
  // we take the first codec we find, as it should be the same for all streams
7783
7328
  report.codec = streams[0].codec || '';
7329
+ report.codecPerTrackType = streams.reduce((acc, stream) => {
7330
+ if (stream.trackType) {
7331
+ acc[stream.trackType] = stream.codec || '';
7332
+ }
7333
+ return acc;
7334
+ }, {});
7335
+ }
7336
+ const qualityLimitationReason = [
7337
+ qualityLimitationReasons.has('cpu') && 'cpu',
7338
+ qualityLimitationReasons.has('bandwidth') && 'bandwidth',
7339
+ qualityLimitationReasons.has('other') && 'other',
7340
+ ]
7341
+ .filter(Boolean)
7342
+ .join(', ');
7343
+ if (qualityLimitationReason) {
7344
+ report.qualityLimitationReasons = qualityLimitationReason;
7345
+ }
7346
+ return report;
7347
+ };
7348
+
7349
+ const version = "1.15.0";
7350
+ const [major, minor, patch] = version.split('.');
7351
+ let sdkInfo = {
7352
+ type: SdkType.PLAIN_JAVASCRIPT,
7353
+ major,
7354
+ minor,
7355
+ patch,
7356
+ };
7357
+ let osInfo;
7358
+ let deviceInfo;
7359
+ let webRtcInfo;
7360
+ let deviceState = { oneofKind: undefined };
7361
+ const setSdkInfo = (info) => {
7362
+ sdkInfo = info;
7363
+ };
7364
+ const getSdkInfo = () => {
7365
+ return sdkInfo;
7366
+ };
7367
+ const setOSInfo = (info) => {
7368
+ osInfo = info;
7369
+ };
7370
+ const getOSInfo = () => {
7371
+ return osInfo;
7372
+ };
7373
+ const setDeviceInfo = (info) => {
7374
+ deviceInfo = info;
7375
+ };
7376
+ const getDeviceInfo = () => {
7377
+ return deviceInfo;
7378
+ };
7379
+ const getWebRTCInfo = () => {
7380
+ return webRtcInfo;
7381
+ };
7382
+ const setWebRTCInfo = (info) => {
7383
+ webRtcInfo = info;
7384
+ };
7385
+ const setThermalState = (state) => {
7386
+ if (!osInfo) {
7387
+ deviceState = { oneofKind: undefined };
7388
+ return;
7389
+ }
7390
+ if (osInfo.name === 'android') {
7391
+ const thermalState = AndroidThermalState[state] ||
7392
+ AndroidThermalState.UNSPECIFIED;
7393
+ deviceState = {
7394
+ oneofKind: 'android',
7395
+ android: {
7396
+ thermalState,
7397
+ isPowerSaverMode: deviceState?.oneofKind === 'android' &&
7398
+ deviceState.android.isPowerSaverMode,
7399
+ },
7400
+ };
7401
+ }
7402
+ if (osInfo.name.toLowerCase() === 'ios') {
7403
+ const thermalState = AppleThermalState[state] ||
7404
+ AppleThermalState.UNSPECIFIED;
7405
+ deviceState = {
7406
+ oneofKind: 'apple',
7407
+ apple: {
7408
+ thermalState,
7409
+ isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
7410
+ deviceState.apple.isLowPowerModeEnabled,
7411
+ },
7412
+ };
7413
+ }
7414
+ };
7415
+ const setPowerState = (powerMode) => {
7416
+ if (!osInfo) {
7417
+ deviceState = { oneofKind: undefined };
7418
+ return;
7419
+ }
7420
+ if (osInfo.name === 'android') {
7421
+ deviceState = {
7422
+ oneofKind: 'android',
7423
+ android: {
7424
+ thermalState: deviceState?.oneofKind === 'android'
7425
+ ? deviceState.android.thermalState
7426
+ : AndroidThermalState.UNSPECIFIED,
7427
+ isPowerSaverMode: powerMode,
7428
+ },
7429
+ };
7430
+ }
7431
+ if (osInfo.name.toLowerCase() === 'ios') {
7432
+ deviceState = {
7433
+ oneofKind: 'apple',
7434
+ apple: {
7435
+ thermalState: deviceState?.oneofKind === 'apple'
7436
+ ? deviceState.apple.thermalState
7437
+ : AppleThermalState.UNSPECIFIED,
7438
+ isLowPowerModeEnabled: powerMode,
7439
+ },
7440
+ };
7784
7441
  }
7785
- const qualityLimitationReason = [
7786
- qualityLimitationReasons.has('cpu') && 'cpu',
7787
- qualityLimitationReasons.has('bandwidth') && 'bandwidth',
7788
- qualityLimitationReasons.has('other') && 'other',
7789
- ]
7790
- .filter(Boolean)
7791
- .join(', ');
7792
- if (qualityLimitationReason) {
7793
- report.qualityLimitationReasons = qualityLimitationReason;
7442
+ };
7443
+ const getDeviceState = () => {
7444
+ return deviceState;
7445
+ };
7446
+ const getClientDetails = () => {
7447
+ if (isReactNative()) {
7448
+ // Since RN doesn't support web, sharing browser info is not required
7449
+ return {
7450
+ sdk: getSdkInfo(),
7451
+ os: getOSInfo(),
7452
+ device: getDeviceInfo(),
7453
+ };
7794
7454
  }
7795
- return report;
7455
+ const userAgent = new UAParser(navigator.userAgent);
7456
+ const { browser, os, device, cpu } = userAgent.getResult();
7457
+ return {
7458
+ sdk: getSdkInfo(),
7459
+ browser: {
7460
+ name: browser.name || navigator.userAgent,
7461
+ version: browser.version || '',
7462
+ },
7463
+ os: {
7464
+ name: os.name || '',
7465
+ version: os.version || '',
7466
+ architecture: cpu.architecture || '',
7467
+ },
7468
+ device: {
7469
+ name: [device.vendor, device.model, device.type]
7470
+ .filter(Boolean)
7471
+ .join(' '),
7472
+ version: '',
7473
+ },
7474
+ };
7796
7475
  };
7797
7476
 
7798
7477
  class SfuStatsReporter {
@@ -7828,8 +7507,28 @@ class SfuStatsReporter {
7828
7507
  });
7829
7508
  });
7830
7509
  };
7831
- this.sendTelemetryData = async (telemetryData) => {
7832
- return this.run(telemetryData);
7510
+ this.sendConnectionTime = (connectionTimeSeconds) => {
7511
+ this.sendTelemetryData({
7512
+ data: {
7513
+ oneofKind: 'connectionTimeSeconds',
7514
+ connectionTimeSeconds,
7515
+ },
7516
+ });
7517
+ };
7518
+ this.sendReconnectionTime = (strategy, timeSeconds) => {
7519
+ this.sendTelemetryData({
7520
+ data: {
7521
+ oneofKind: 'reconnection',
7522
+ reconnection: { strategy, timeSeconds },
7523
+ },
7524
+ });
7525
+ };
7526
+ this.sendTelemetryData = (telemetryData) => {
7527
+ // intentionally not awaiting the promise here
7528
+ // to avoid impeding with the ongoing actions.
7529
+ this.run(telemetryData).catch((err) => {
7530
+ this.logger('warn', 'Failed to send telemetry data', err);
7531
+ });
7833
7532
  };
7834
7533
  this.run = async (telemetryData) => {
7835
7534
  const [subscriberStats, publisherStats] = await Promise.all([
@@ -8397,6 +8096,25 @@ class PermissionsContext {
8397
8096
  this.hasPermission = (permission) => {
8398
8097
  return this.permissions.includes(permission);
8399
8098
  };
8099
+ /**
8100
+ * Helper method that checks whether the current user has the permission
8101
+ * to publish the given track type.
8102
+ */
8103
+ this.canPublish = (trackType) => {
8104
+ switch (trackType) {
8105
+ case TrackType.AUDIO:
8106
+ return this.hasPermission(OwnCapability.SEND_AUDIO);
8107
+ case TrackType.VIDEO:
8108
+ return this.hasPermission(OwnCapability.SEND_VIDEO);
8109
+ case TrackType.SCREEN_SHARE:
8110
+ case TrackType.SCREEN_SHARE_AUDIO:
8111
+ return this.hasPermission(OwnCapability.SCREENSHARE);
8112
+ case TrackType.UNSPECIFIED:
8113
+ return false;
8114
+ default:
8115
+ ensureExhausted(trackType, 'Unknown track type');
8116
+ }
8117
+ };
8400
8118
  /**
8401
8119
  * Checks if the current user can request a specific permission
8402
8120
  * within the call.
@@ -9035,36 +8753,42 @@ class InputMediaDeviceManager {
9035
8753
  }
9036
8754
  });
9037
8755
  }
8756
+ publishStream(stream) {
8757
+ return this.call.publish(stream, this.trackType);
8758
+ }
8759
+ stopPublishStream() {
8760
+ return this.call.stopPublish(this.trackType);
8761
+ }
9038
8762
  getTracks() {
9039
8763
  return this.state.mediaStream?.getTracks() ?? [];
9040
8764
  }
9041
8765
  async muteStream(stopTracks = true) {
9042
- if (!this.state.mediaStream)
8766
+ const mediaStream = this.state.mediaStream;
8767
+ if (!mediaStream)
9043
8768
  return;
9044
8769
  this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
9045
8770
  if (this.call.state.callingState === CallingState.JOINED) {
9046
- await this.stopPublishStream(stopTracks);
8771
+ await this.stopPublishStream();
9047
8772
  }
9048
8773
  this.muteLocalStream(stopTracks);
9049
8774
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
9050
8775
  if (allEnded) {
9051
- if (this.state.mediaStream &&
9052
- // @ts-expect-error release() is present in react-native-webrtc
9053
- typeof this.state.mediaStream.release === 'function') {
8776
+ // @ts-expect-error release() is present in react-native-webrtc
8777
+ if (typeof mediaStream.release === 'function') {
9054
8778
  // @ts-expect-error called to dispose the stream in RN
9055
- this.state.mediaStream.release();
8779
+ mediaStream.release();
9056
8780
  }
9057
8781
  this.state.setMediaStream(undefined, undefined);
9058
8782
  this.filters.forEach((entry) => entry.stop?.());
9059
8783
  }
9060
8784
  }
9061
- muteTracks() {
8785
+ disableTracks() {
9062
8786
  this.getTracks().forEach((track) => {
9063
8787
  if (track.enabled)
9064
8788
  track.enabled = false;
9065
8789
  });
9066
8790
  }
9067
- unmuteTracks() {
8791
+ enableTracks() {
9068
8792
  this.getTracks().forEach((track) => {
9069
8793
  if (!track.enabled)
9070
8794
  track.enabled = true;
@@ -9084,7 +8808,7 @@ class InputMediaDeviceManager {
9084
8808
  this.stopTracks();
9085
8809
  }
9086
8810
  else {
9087
- this.muteTracks();
8811
+ this.disableTracks();
9088
8812
  }
9089
8813
  }
9090
8814
  async unmuteStream() {
@@ -9094,7 +8818,7 @@ class InputMediaDeviceManager {
9094
8818
  if (this.state.mediaStream &&
9095
8819
  this.getTracks().every((t) => t.readyState === 'live')) {
9096
8820
  stream = this.state.mediaStream;
9097
- this.unmuteTracks();
8821
+ this.enableTracks();
9098
8822
  }
9099
8823
  else {
9100
8824
  const defaultConstraints = this.state.defaultConstraints;
@@ -9188,9 +8912,22 @@ class InputMediaDeviceManager {
9188
8912
  await this.disable();
9189
8913
  }
9190
8914
  };
9191
- this.getTracks().forEach((track) => {
8915
+ const createTrackMuteHandler = (muted) => () => {
8916
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
8917
+ this.logger('warn', 'Error while notifying track mute state', err);
8918
+ });
8919
+ };
8920
+ stream.getTracks().forEach((track) => {
8921
+ const muteHandler = createTrackMuteHandler(true);
8922
+ const unmuteHandler = createTrackMuteHandler(false);
8923
+ track.addEventListener('mute', muteHandler);
8924
+ track.addEventListener('unmute', unmuteHandler);
9192
8925
  track.addEventListener('ended', handleTrackEnded);
9193
- this.subscriptions.push(() => track.removeEventListener('ended', handleTrackEnded));
8926
+ this.subscriptions.push(() => {
8927
+ track.removeEventListener('mute', muteHandler);
8928
+ track.removeEventListener('unmute', unmuteHandler);
8929
+ track.removeEventListener('ended', handleTrackEnded);
8930
+ });
9194
8931
  });
9195
8932
  }
9196
8933
  }
@@ -9214,8 +8951,8 @@ class InputMediaDeviceManager {
9214
8951
  await this.statusChangeSettled();
9215
8952
  let isDeviceDisconnected = false;
9216
8953
  let isDeviceReplaced = false;
9217
- const currentDevice = this.findDeviceInList(currentDevices, deviceId);
9218
- const prevDevice = this.findDeviceInList(prevDevices, deviceId);
8954
+ const currentDevice = this.findDevice(currentDevices, deviceId);
8955
+ const prevDevice = this.findDevice(prevDevices, deviceId);
9219
8956
  if (!currentDevice && prevDevice) {
9220
8957
  isDeviceDisconnected = true;
9221
8958
  }
@@ -9245,8 +8982,9 @@ class InputMediaDeviceManager {
9245
8982
  }
9246
8983
  }));
9247
8984
  }
9248
- findDeviceInList(devices, deviceId) {
9249
- return devices.find((d) => d.deviceId === deviceId && d.kind === this.mediaDeviceKind);
8985
+ findDevice(devices, deviceId) {
8986
+ const kind = this.mediaDeviceKind;
8987
+ return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
9250
8988
  }
9251
8989
  }
9252
8990
 
@@ -9510,14 +9248,35 @@ class CameraManager extends InputMediaDeviceManager {
9510
9248
  }
9511
9249
  }
9512
9250
  /**
9513
- * Sets the preferred codec for encoding the video.
9251
+ * Applies the video settings to the camera.
9514
9252
  *
9515
- * @internal internal use only, not part of the public API.
9516
- * @deprecated use {@link call.updatePublishOptions} instead.
9517
- * @param codec the codec to use for encoding the video.
9253
+ * @param settings the video settings to apply.
9254
+ * @param publish whether to publish the stream after applying the settings.
9518
9255
  */
9519
- setPreferredCodec(codec) {
9520
- this.call.updatePublishOptions({ preferredCodec: codec });
9256
+ async apply(settings, publish) {
9257
+ const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
9258
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9259
+ if (hasPublishedVideo || !hasPermission)
9260
+ return;
9261
+ // Wait for any in progress camera operation
9262
+ await this.statusChangeSettled();
9263
+ const { target_resolution, camera_facing, camera_default_on } = settings;
9264
+ await this.selectTargetResolution(target_resolution);
9265
+ // Set camera direction if it's not yet set
9266
+ if (!this.state.direction && !this.state.selectedDevice) {
9267
+ this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
9268
+ }
9269
+ if (!publish)
9270
+ return;
9271
+ const { mediaStream } = this.state;
9272
+ if (this.enabled && mediaStream) {
9273
+ // The camera is already enabled (e.g. lobby screen). Publish the stream
9274
+ await this.publishStream(mediaStream);
9275
+ }
9276
+ else if (this.state.status === undefined && camera_default_on) {
9277
+ // Start camera if backend config specifies, and there is no local setting
9278
+ await this.enable();
9279
+ }
9521
9280
  }
9522
9281
  getDevices() {
9523
9282
  return getVideoDevices();
@@ -9535,12 +9294,6 @@ class CameraManager extends InputMediaDeviceManager {
9535
9294
  }
9536
9295
  return getVideoStream(constraints);
9537
9296
  }
9538
- publishStream(stream) {
9539
- return this.call.publishVideoStream(stream);
9540
- }
9541
- stopPublishStream(stopTracks) {
9542
- return this.call.stopPublish(TrackType.VIDEO, stopTracks);
9543
- }
9544
9297
  }
9545
9298
 
9546
9299
  class MicrophoneManagerState extends InputMediaDeviceManagerState {
@@ -9868,18 +9621,37 @@ class MicrophoneManager extends InputMediaDeviceManager {
9868
9621
  this.speakingWhileMutedNotificationEnabled = false;
9869
9622
  await this.stopSpeakingWhileMutedDetection();
9870
9623
  }
9624
+ /**
9625
+ * Applies the audio settings to the microphone.
9626
+ * @param settings the audio settings to apply.
9627
+ * @param publish whether to publish the stream after applying the settings.
9628
+ */
9629
+ async apply(settings, publish) {
9630
+ if (!publish)
9631
+ return;
9632
+ const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
9633
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9634
+ if (hasPublishedAudio || !hasPermission)
9635
+ return;
9636
+ // Wait for any in progress mic operation
9637
+ await this.statusChangeSettled();
9638
+ // Publish media stream that was set before we joined
9639
+ const { mediaStream } = this.state;
9640
+ if (this.enabled && mediaStream) {
9641
+ // The mic is already enabled (e.g. lobby screen). Publish the stream
9642
+ await this.publishStream(mediaStream);
9643
+ }
9644
+ else if (this.state.status === undefined && settings.mic_default_on) {
9645
+ // Start mic if backend config specifies, and there is no local setting
9646
+ await this.enable();
9647
+ }
9648
+ }
9871
9649
  getDevices() {
9872
9650
  return getAudioDevices();
9873
9651
  }
9874
9652
  getStream(constraints) {
9875
9653
  return getAudioStream(constraints);
9876
9654
  }
9877
- publishStream(stream) {
9878
- return this.call.publishAudioStream(stream);
9879
- }
9880
- stopPublishStream(stopTracks) {
9881
- return this.call.stopPublish(TrackType.AUDIO, stopTracks);
9882
- }
9883
9655
  async startSpeakingWhileMutedDetection(deviceId) {
9884
9656
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
9885
9657
  await this.stopSpeakingWhileMutedDetection();
@@ -9999,7 +9771,7 @@ class ScreenShareManager extends InputMediaDeviceManager {
9999
9771
  async disableScreenShareAudio() {
10000
9772
  this.state.setAudioEnabled(false);
10001
9773
  if (this.call.publisher?.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
10002
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, true);
9774
+ await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO);
10003
9775
  }
10004
9776
  }
10005
9777
  /**
@@ -10025,12 +9797,8 @@ class ScreenShareManager extends InputMediaDeviceManager {
10025
9797
  }
10026
9798
  return getScreenShareStream(constraints);
10027
9799
  }
10028
- publishStream(stream) {
10029
- return this.call.publishScreenShareStream(stream);
10030
- }
10031
- async stopPublishStream(stopTracks) {
10032
- await this.call.stopPublish(TrackType.SCREEN_SHARE, stopTracks);
10033
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, stopTracks);
9800
+ async stopPublishStream() {
9801
+ return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
10034
9802
  }
10035
9803
  /**
10036
9804
  * Overrides the default `select` method to throw an error.
@@ -10240,6 +10008,112 @@ class Call {
10240
10008
  */
10241
10009
  this.leaveCallHooks = new Set();
10242
10010
  this.streamClientEventHandlers = new Map();
10011
+ this.setup = async () => {
10012
+ await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
10013
+ if (this.initialized)
10014
+ return;
10015
+ this.leaveCallHooks.add(this.on('all', (event) => {
10016
+ // update state with the latest event data
10017
+ this.state.updateFromEvent(event);
10018
+ }));
10019
+ this.leaveCallHooks.add(this.on('changePublishOptions', (event) => {
10020
+ this.currentPublishOptions = event.publishOptions;
10021
+ }));
10022
+ this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
10023
+ this.registerEffects();
10024
+ this.registerReconnectHandlers();
10025
+ if (this.state.callingState === CallingState.LEFT) {
10026
+ this.state.setCallingState(CallingState.IDLE);
10027
+ }
10028
+ this.initialized = true;
10029
+ });
10030
+ };
10031
+ this.registerEffects = () => {
10032
+ this.leaveCallHooks.add(
10033
+ // handles updating the permissions context when the settings change.
10034
+ createSubscription(this.state.settings$, (settings) => {
10035
+ if (!settings)
10036
+ return;
10037
+ this.permissionsContext.setCallSettings(settings);
10038
+ }));
10039
+ this.leaveCallHooks.add(
10040
+ // handle the case when the user permissions are modified.
10041
+ createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
10042
+ this.leaveCallHooks.add(
10043
+ // handles the case when the user is blocked by the call owner.
10044
+ createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
10045
+ if (!blockedUserIds || blockedUserIds.length === 0)
10046
+ return;
10047
+ const currentUserId = this.currentUserId;
10048
+ if (currentUserId && blockedUserIds.includes(currentUserId)) {
10049
+ this.logger('info', 'Leaving call because of being blocked');
10050
+ await this.leave({ reason: 'user blocked' }).catch((err) => {
10051
+ this.logger('error', 'Error leaving call after being blocked', err);
10052
+ });
10053
+ }
10054
+ }));
10055
+ this.leaveCallHooks.add(
10056
+ // cancel auto-drop when call is
10057
+ createSubscription(this.state.session$, (session) => {
10058
+ if (!this.ringing)
10059
+ return;
10060
+ const receiverId = this.clientStore.connectedUser?.id;
10061
+ if (!receiverId)
10062
+ return;
10063
+ const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
10064
+ const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
10065
+ if (isAcceptedByMe || isRejectedByMe) {
10066
+ this.cancelAutoDrop();
10067
+ }
10068
+ }));
10069
+ this.leaveCallHooks.add(
10070
+ // "ringing" mode effects and event handlers
10071
+ createSubscription(this.ringingSubject, (isRinging) => {
10072
+ if (!isRinging)
10073
+ return;
10074
+ const callSession = this.state.session;
10075
+ const receiver_id = this.clientStore.connectedUser?.id;
10076
+ const ended_at = callSession?.ended_at;
10077
+ const created_by_id = this.state.createdBy?.id;
10078
+ const rejected_by = callSession?.rejected_by;
10079
+ const accepted_by = callSession?.accepted_by;
10080
+ let leaveCallIdle = false;
10081
+ if (ended_at) {
10082
+ // call was ended before it was accepted or rejected so we should leave it to idle
10083
+ leaveCallIdle = true;
10084
+ }
10085
+ else if (created_by_id && rejected_by) {
10086
+ if (rejected_by[created_by_id]) {
10087
+ // call was cancelled by the caller
10088
+ leaveCallIdle = true;
10089
+ }
10090
+ }
10091
+ else if (receiver_id && rejected_by) {
10092
+ if (rejected_by[receiver_id]) {
10093
+ // call was rejected by the receiver in some other device
10094
+ leaveCallIdle = true;
10095
+ }
10096
+ }
10097
+ else if (receiver_id && accepted_by) {
10098
+ if (accepted_by[receiver_id]) {
10099
+ // call was accepted by the receiver in some other device
10100
+ leaveCallIdle = true;
10101
+ }
10102
+ }
10103
+ if (leaveCallIdle) {
10104
+ if (this.state.callingState !== CallingState.IDLE) {
10105
+ this.state.setCallingState(CallingState.IDLE);
10106
+ }
10107
+ }
10108
+ else {
10109
+ if (this.state.callingState === CallingState.IDLE) {
10110
+ this.state.setCallingState(CallingState.RINGING);
10111
+ }
10112
+ this.scheduleAutoDrop();
10113
+ this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
10114
+ }
10115
+ }));
10116
+ };
10243
10117
  this.handleOwnCapabilitiesUpdated = async (ownCapabilities) => {
10244
10118
  // update the permission context.
10245
10119
  this.permissionsContext.setPermissions(ownCapabilities);
@@ -10352,9 +10226,9 @@ class Call {
10352
10226
  this.statsReporter = undefined;
10353
10227
  this.sfuStatsReporter?.stop();
10354
10228
  this.sfuStatsReporter = undefined;
10355
- this.subscriber?.close();
10229
+ this.subscriber?.dispose();
10356
10230
  this.subscriber = undefined;
10357
- this.publisher?.close({ stopTracks: true });
10231
+ this.publisher?.dispose();
10358
10232
  this.publisher = undefined;
10359
10233
  await this.sfuClient?.leaveAndClose(reason);
10360
10234
  this.sfuClient = undefined;
@@ -10392,7 +10266,8 @@ class Call {
10392
10266
  // call.ring event excludes the call creator in the members list
10393
10267
  // as the creator does not get the ring event
10394
10268
  // so update the member list accordingly
10395
- const creator = this.state.members.find((m) => m.user.id === event.call.created_by.id);
10269
+ const { created_by, settings } = event.call;
10270
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
10396
10271
  if (!creator) {
10397
10272
  this.state.setMembers(event.members);
10398
10273
  }
@@ -10407,7 +10282,7 @@ class Call {
10407
10282
  // const calls = useCalls().filter((c) => c.ringing);
10408
10283
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
10409
10284
  this.clientStore.setCalls([this, ...calls]);
10410
- await this.applyDeviceConfig(false);
10285
+ await this.applyDeviceConfig(settings, false);
10411
10286
  };
10412
10287
  /**
10413
10288
  * Loads the information about the call.
@@ -10430,7 +10305,7 @@ class Call {
10430
10305
  this.watching = true;
10431
10306
  this.clientStore.registerCall(this);
10432
10307
  }
10433
- await this.applyDeviceConfig(false);
10308
+ await this.applyDeviceConfig(response.call.settings, false);
10434
10309
  return response;
10435
10310
  };
10436
10311
  /**
@@ -10452,7 +10327,7 @@ class Call {
10452
10327
  this.watching = true;
10453
10328
  this.clientStore.registerCall(this);
10454
10329
  }
10455
- await this.applyDeviceConfig(false);
10330
+ await this.applyDeviceConfig(response.call.settings, false);
10456
10331
  return response;
10457
10332
  };
10458
10333
  /**
@@ -10554,19 +10429,32 @@ class Call {
10554
10429
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
10555
10430
  if (previousSfuClient !== sfuClient) {
10556
10431
  // prepare a generic SDP and send it to the SFU.
10557
- // this is a throw-away SDP that the SFU will use to determine
10432
+ // these are throw-away SDPs that the SFU will use to determine
10558
10433
  // the capabilities of the client (codec support, etc.)
10559
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
10560
- const reconnectDetails = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
10434
+ const [subscriberSdp, publisherSdp] = await Promise.all([
10435
+ getGenericSdp('recvonly'),
10436
+ getGenericSdp('sendonly'),
10437
+ ]);
10438
+ const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
10439
+ const reconnectDetails = isReconnecting
10561
10440
  ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
10562
10441
  : undefined;
10563
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
10564
- subscriberSdp: receivingCapabilitiesSdp,
10565
- publisherSdp: '',
10442
+ const preferredPublishOptions = !isReconnecting
10443
+ ? this.getPreferredPublishOptions()
10444
+ : this.currentPublishOptions || [];
10445
+ const preferredSubscribeOptions = !isReconnecting
10446
+ ? this.getPreferredSubscribeOptions()
10447
+ : [];
10448
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
10449
+ subscriberSdp,
10450
+ publisherSdp,
10566
10451
  clientDetails,
10567
10452
  fastReconnect: performingFastReconnect,
10568
10453
  reconnectDetails,
10454
+ preferredPublishOptions,
10455
+ preferredSubscribeOptions,
10569
10456
  });
10457
+ this.currentPublishOptions = publishOptions;
10570
10458
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
10571
10459
  if (callState) {
10572
10460
  this.state.updateFromSfuCallState(callState, sfuClient.sessionId, reconnectDetails);
@@ -10591,17 +10479,13 @@ class Call {
10591
10479
  connectionConfig,
10592
10480
  clientDetails,
10593
10481
  statsOptions,
10482
+ publishOptions: this.currentPublishOptions || [],
10594
10483
  closePreviousInstances: !performingMigration,
10595
10484
  });
10596
10485
  }
10597
10486
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
10598
10487
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
10599
- this.sfuStatsReporter?.sendTelemetryData({
10600
- data: {
10601
- oneofKind: 'connectionTimeSeconds',
10602
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
10603
- },
10604
- });
10488
+ this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
10605
10489
  }
10606
10490
  if (performingRejoin) {
10607
10491
  const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
@@ -10612,8 +10496,8 @@ class Call {
10612
10496
  }
10613
10497
  // device settings should be applied only once, we don't have to
10614
10498
  // re-apply them on later reconnections or server-side data fetches
10615
- if (!this.deviceSettingsAppliedOnce) {
10616
- await this.applyDeviceConfig(true);
10499
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
10500
+ await this.applyDeviceConfig(this.state.settings, true);
10617
10501
  this.deviceSettingsAppliedOnce = true;
10618
10502
  }
10619
10503
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -10622,6 +10506,8 @@ class Call {
10622
10506
  // we will spam the other participants with push notifications and `call.ring` events.
10623
10507
  delete this.joinCallData?.ring;
10624
10508
  delete this.joinCallData?.notify;
10509
+ // reset the reconnect strategy to unspecified after a successful reconnection
10510
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
10625
10511
  this.logger('info', `Joined call ${this.cid}`);
10626
10512
  };
10627
10513
  /**
@@ -10631,7 +10517,7 @@ class Call {
10631
10517
  this.getReconnectDetails = (migratingFromSfuId, previousSessionId) => {
10632
10518
  const strategy = this.reconnectStrategy;
10633
10519
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
10634
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
10520
+ const announcedTracks = this.publisher?.getAnnouncedTracksForReconnect() || [];
10635
10521
  return {
10636
10522
  strategy,
10637
10523
  announcedTracks,
@@ -10641,6 +10527,54 @@ class Call {
10641
10527
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
10642
10528
  };
10643
10529
  };
10530
+ /**
10531
+ * Prepares the preferred codec for the call.
10532
+ * This is an experimental client feature and subject to change.
10533
+ * @internal
10534
+ */
10535
+ this.getPreferredPublishOptions = () => {
10536
+ const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } = this.clientPublishOptions || {};
10537
+ if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers)
10538
+ return [];
10539
+ const codec = preferredCodec
10540
+ ? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
10541
+ : undefined;
10542
+ const preferredPublishOptions = [
10543
+ PublishOption.create({
10544
+ trackType: TrackType.VIDEO,
10545
+ codec,
10546
+ bitrate: preferredBitrate,
10547
+ maxSpatialLayers: maxSimulcastLayers,
10548
+ }),
10549
+ ];
10550
+ const screenShareSettings = this.screenShare.getSettings();
10551
+ if (screenShareSettings) {
10552
+ preferredPublishOptions.push(PublishOption.create({
10553
+ trackType: TrackType.SCREEN_SHARE,
10554
+ fps: screenShareSettings.maxFramerate,
10555
+ bitrate: screenShareSettings.maxBitrate,
10556
+ }));
10557
+ }
10558
+ return preferredPublishOptions;
10559
+ };
10560
+ /**
10561
+ * Prepares the preferred options for subscribing to tracks.
10562
+ * This is an experimental client feature and subject to change.
10563
+ * @internal
10564
+ */
10565
+ this.getPreferredSubscribeOptions = () => {
10566
+ const { subscriberCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
10567
+ if (!subscriberCodec || !subscriberFmtpLine)
10568
+ return [];
10569
+ return [
10570
+ SubscribeOption.create({
10571
+ trackType: TrackType.VIDEO,
10572
+ codecs: [
10573
+ { name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
10574
+ ],
10575
+ }),
10576
+ ];
10577
+ };
10644
10578
  /**
10645
10579
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
10646
10580
  * Uses the provided SFU client to restore the ICE connection.
@@ -10671,9 +10605,9 @@ class Call {
10671
10605
  * @internal
10672
10606
  */
10673
10607
  this.initPublisherAndSubscriber = (opts) => {
10674
- const { sfuClient, connectionConfig, clientDetails, statsOptions, closePreviousInstances, } = opts;
10608
+ const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, } = opts;
10675
10609
  if (closePreviousInstances && this.subscriber) {
10676
- this.subscriber.close();
10610
+ this.subscriber.dispose();
10677
10611
  }
10678
10612
  this.subscriber = new Subscriber({
10679
10613
  sfuClient,
@@ -10692,18 +10626,14 @@ class Call {
10692
10626
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
10693
10627
  if (!isAnonymous) {
10694
10628
  if (closePreviousInstances && this.publisher) {
10695
- this.publisher.close({ stopTracks: false });
10629
+ this.publisher.dispose();
10696
10630
  }
10697
- const audioSettings = this.state.settings?.audio;
10698
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
10699
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
10700
10631
  this.publisher = new Publisher({
10701
10632
  sfuClient,
10702
10633
  dispatcher: this.dispatcher,
10703
10634
  state: this.state,
10704
10635
  connectionConfig,
10705
- isDtxEnabled,
10706
- isRedEnabled,
10636
+ publishOptions,
10707
10637
  logTag: String(this.sfuClientTag),
10708
10638
  onUnrecoverableError: () => {
10709
10639
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -10850,47 +10780,31 @@ class Call {
10850
10780
  * @internal
10851
10781
  */
10852
10782
  this.reconnectFast = async () => {
10853
- let reconnectStartTime = Date.now();
10783
+ const reconnectStartTime = Date.now();
10854
10784
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
10855
10785
  this.state.setCallingState(CallingState.RECONNECTING);
10856
10786
  await this.join(this.joinCallData);
10857
- this.sfuStatsReporter?.sendTelemetryData({
10858
- data: {
10859
- oneofKind: 'reconnection',
10860
- reconnection: {
10861
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10862
- strategy: WebsocketReconnectStrategy.FAST,
10863
- },
10864
- },
10865
- });
10787
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
10866
10788
  };
10867
10789
  /**
10868
10790
  * Initiates the reconnection flow with the "rejoin" strategy.
10869
10791
  * @internal
10870
10792
  */
10871
10793
  this.reconnectRejoin = async () => {
10872
- let reconnectStartTime = Date.now();
10794
+ const reconnectStartTime = Date.now();
10873
10795
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
10874
10796
  this.state.setCallingState(CallingState.RECONNECTING);
10875
10797
  await this.join(this.joinCallData);
10876
10798
  await this.restorePublishedTracks();
10877
10799
  this.restoreSubscribedTracks();
10878
- this.sfuStatsReporter?.sendTelemetryData({
10879
- data: {
10880
- oneofKind: 'reconnection',
10881
- reconnection: {
10882
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10883
- strategy: WebsocketReconnectStrategy.REJOIN,
10884
- },
10885
- },
10886
- });
10800
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
10887
10801
  };
10888
10802
  /**
10889
10803
  * Initiates the reconnection flow with the "migrate" strategy.
10890
10804
  * @internal
10891
10805
  */
10892
10806
  this.reconnectMigrate = async () => {
10893
- let reconnectStartTime = Date.now();
10807
+ const reconnectStartTime = Date.now();
10894
10808
  const currentSfuClient = this.sfuClient;
10895
10809
  if (!currentSfuClient) {
10896
10810
  throw new Error('Cannot migrate without an active SFU client');
@@ -10924,20 +10838,12 @@ class Call {
10924
10838
  this.state.setCallingState(CallingState.JOINED);
10925
10839
  }
10926
10840
  finally {
10927
- currentSubscriber?.close();
10928
- currentPublisher?.close({ stopTracks: false });
10841
+ currentSubscriber?.dispose();
10842
+ currentPublisher?.dispose();
10929
10843
  // and close the previous SFU client, without specifying close code
10930
10844
  currentSfuClient.close();
10931
10845
  }
10932
- this.sfuStatsReporter?.sendTelemetryData({
10933
- data: {
10934
- oneofKind: 'reconnection',
10935
- reconnection: {
10936
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10937
- strategy: WebsocketReconnectStrategy.MIGRATE,
10938
- },
10939
- },
10940
- });
10846
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.MIGRATE, (Date.now() - reconnectStartTime) / 1000);
10941
10847
  };
10942
10848
  /**
10943
10849
  * Registers the various event handlers for reconnection.
@@ -11014,23 +10920,16 @@ class Call {
11014
10920
  // the tracks need to be restored in their original order of publishing
11015
10921
  // otherwise, we might get `m-lines order mismatch` errors
11016
10922
  for (const trackType of this.trackPublishOrder) {
10923
+ let mediaStream;
11017
10924
  switch (trackType) {
11018
10925
  case TrackType.AUDIO:
11019
- const audioStream = this.microphone.state.mediaStream;
11020
- if (audioStream) {
11021
- await this.publishAudioStream(audioStream);
11022
- }
10926
+ mediaStream = this.microphone.state.mediaStream;
11023
10927
  break;
11024
10928
  case TrackType.VIDEO:
11025
- const videoStream = this.camera.state.mediaStream;
11026
- if (videoStream)
11027
- await this.publishVideoStream(videoStream);
10929
+ mediaStream = this.camera.state.mediaStream;
11028
10930
  break;
11029
10931
  case TrackType.SCREEN_SHARE:
11030
- const screenShareStream = this.screenShare.state.mediaStream;
11031
- if (screenShareStream) {
11032
- await this.publishScreenShareStream(screenShareStream);
11033
- }
10932
+ mediaStream = this.screenShare.state.mediaStream;
11034
10933
  break;
11035
10934
  // screen share audio can't exist without a screen share, so we handle it there
11036
10935
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -11040,6 +10939,8 @@ class Call {
11040
10939
  ensureExhausted(trackType, 'Unknown track type');
11041
10940
  break;
11042
10941
  }
10942
+ if (mediaStream)
10943
+ await this.publish(mediaStream, trackType);
11043
10944
  }
11044
10945
  };
11045
10946
  /**
@@ -11054,105 +10955,111 @@ class Call {
11054
10955
  };
11055
10956
  /**
11056
10957
  * Starts publishing the given video stream to the call.
11057
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
11058
- *
11059
- * Consecutive calls to this method will replace the previously published stream.
11060
- * The previous video stream will be stopped.
11061
- *
11062
- * @param videoStream the video stream to publish.
10958
+ * @deprecated use `call.publish()`.
11063
10959
  */
11064
10960
  this.publishVideoStream = async (videoStream) => {
11065
- if (!this.sfuClient)
11066
- throw new Error(`Call not joined yet.`);
11067
- // joining is in progress, and we should wait until the client is ready
11068
- await this.sfuClient.joinTask;
11069
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
11070
- throw new Error('No permission to publish video');
11071
- }
11072
- if (!this.publisher)
11073
- throw new Error('Publisher is not initialized');
11074
- const [videoTrack] = videoStream.getVideoTracks();
11075
- if (!videoTrack)
11076
- throw new Error('There is no video track in the stream');
11077
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
11078
- this.trackPublishOrder.push(TrackType.VIDEO);
11079
- }
11080
- await this.publisher.publishStream(videoStream, videoTrack, TrackType.VIDEO, this.publishOptions);
10961
+ await this.publish(videoStream, TrackType.VIDEO);
11081
10962
  };
11082
10963
  /**
11083
10964
  * Starts publishing the given audio stream to the call.
11084
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
11085
- *
11086
- * Consecutive calls to this method will replace the audio stream that is currently being published.
11087
- * The previous audio stream will be stopped.
11088
- *
11089
- * @param audioStream the audio stream to publish.
10965
+ * @deprecated use `call.publish()`
11090
10966
  */
11091
10967
  this.publishAudioStream = async (audioStream) => {
11092
- if (!this.sfuClient)
11093
- throw new Error(`Call not joined yet.`);
11094
- // joining is in progress, and we should wait until the client is ready
11095
- await this.sfuClient.joinTask;
11096
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
11097
- throw new Error('No permission to publish audio');
11098
- }
11099
- if (!this.publisher)
11100
- throw new Error('Publisher is not initialized');
11101
- const [audioTrack] = audioStream.getAudioTracks();
11102
- if (!audioTrack)
11103
- throw new Error('There is no audio track in the stream');
11104
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
11105
- this.trackPublishOrder.push(TrackType.AUDIO);
11106
- }
11107
- await this.publisher.publishStream(audioStream, audioTrack, TrackType.AUDIO);
10968
+ await this.publish(audioStream, TrackType.AUDIO);
11108
10969
  };
11109
10970
  /**
11110
10971
  * Starts publishing the given screen-share stream to the call.
11111
- *
11112
- * Consecutive calls to this method will replace the previous screen-share stream.
11113
- * The previous screen-share stream will be stopped.
11114
- *
11115
- * @param screenShareStream the screen-share stream to publish.
10972
+ * @deprecated use `call.publish()`
11116
10973
  */
11117
10974
  this.publishScreenShareStream = async (screenShareStream) => {
10975
+ await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
10976
+ };
10977
+ /**
10978
+ * Publishes the given media stream.
10979
+ *
10980
+ * @param mediaStream the media stream to publish.
10981
+ * @param trackType the type of the track to announce.
10982
+ */
10983
+ this.publish = async (mediaStream, trackType) => {
11118
10984
  if (!this.sfuClient)
11119
10985
  throw new Error(`Call not joined yet.`);
11120
10986
  // joining is in progress, and we should wait until the client is ready
11121
10987
  await this.sfuClient.joinTask;
11122
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
11123
- throw new Error('No permission to publish screen share');
10988
+ if (!this.permissionsContext.canPublish(trackType)) {
10989
+ throw new Error(`No permission to publish ${TrackType[trackType]}`);
11124
10990
  }
11125
10991
  if (!this.publisher)
11126
10992
  throw new Error('Publisher is not initialized');
11127
- const [screenShareTrack] = screenShareStream.getVideoTracks();
11128
- if (!screenShareTrack) {
11129
- throw new Error('There is no screen share track in the stream');
10993
+ const [track] = isAudioTrackType(trackType)
10994
+ ? mediaStream.getAudioTracks()
10995
+ : mediaStream.getVideoTracks();
10996
+ if (!track) {
10997
+ throw new Error(`There is no ${TrackType[trackType]} track in the stream`);
11130
10998
  }
11131
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
11132
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
11133
- }
11134
- const opts = {
11135
- screenShareSettings: this.screenShare.getSettings(),
11136
- };
11137
- await this.publisher.publishStream(screenShareStream, screenShareTrack, TrackType.SCREEN_SHARE, opts);
11138
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
11139
- if (screenShareAudioTrack) {
11140
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
11141
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
10999
+ if (track.readyState === 'ended') {
11000
+ throw new Error(`Can't publish ended tracks.`);
11001
+ }
11002
+ pushToIfMissing(this.trackPublishOrder, trackType);
11003
+ await this.publisher.publish(track, trackType);
11004
+ const trackTypes = [trackType];
11005
+ if (trackType === TrackType.SCREEN_SHARE) {
11006
+ const [audioTrack] = mediaStream.getAudioTracks();
11007
+ if (audioTrack) {
11008
+ pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
11009
+ await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
11010
+ trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
11142
11011
  }
11143
- await this.publisher.publishStream(screenShareStream, screenShareAudioTrack, TrackType.SCREEN_SHARE_AUDIO, opts);
11144
11012
  }
11013
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
11145
11014
  };
11146
11015
  /**
11147
11016
  * Stops publishing the given track type to the call, if it is currently being published.
11148
- * Underlying track will be stopped and removed from the publisher.
11149
11017
  *
11150
- * @param trackType the track type to stop publishing.
11151
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
11018
+ * @param trackTypes the track types to stop publishing.
11019
+ */
11020
+ this.stopPublish = async (...trackTypes) => {
11021
+ if (!this.sfuClient || !this.publisher)
11022
+ return;
11023
+ this.publisher.stopTracks(...trackTypes);
11024
+ await this.updateLocalStreamState(undefined, ...trackTypes);
11025
+ };
11026
+ /**
11027
+ * Updates the call state with the new stream.
11028
+ *
11029
+ * @param mediaStream the new stream to update the call state with.
11030
+ * If undefined, the stream will be removed from the call state.
11031
+ * @param trackTypes the track types to update the call state with.
11032
+ */
11033
+ this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
11034
+ if (!this.sfuClient || !this.sfuClient.sessionId)
11035
+ return;
11036
+ await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
11037
+ const { sessionId } = this.sfuClient;
11038
+ for (const trackType of trackTypes) {
11039
+ const streamStateProp = trackTypeToParticipantStreamKey(trackType);
11040
+ if (!streamStateProp)
11041
+ continue;
11042
+ this.state.updateParticipant(sessionId, (p) => ({
11043
+ publishedTracks: mediaStream
11044
+ ? pushToIfMissing([...p.publishedTracks], trackType)
11045
+ : p.publishedTracks.filter((t) => t !== trackType),
11046
+ [streamStateProp]: mediaStream,
11047
+ }));
11048
+ }
11049
+ };
11050
+ /**
11051
+ * Updates the preferred publishing options
11052
+ *
11053
+ * @internal
11054
+ * @param options the options to use.
11152
11055
  */
11153
- this.stopPublish = async (trackType, stopTrack = true) => {
11154
- this.logger('info', `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`);
11155
- await this.publisher?.unpublishStream(trackType, stopTrack);
11056
+ this.updatePublishOptions = (options) => {
11057
+ this.logger('warn', '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
11058
+ 'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.');
11059
+ if (this.state.callingState === CallingState.JOINED) {
11060
+ this.logger('warn', 'Updating publish options after joining the call does not have an effect');
11061
+ }
11062
+ this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
11156
11063
  };
11157
11064
  /**
11158
11065
  * Notifies the SFU that a noise cancellation process has started.
@@ -11174,6 +11081,15 @@ class Call {
11174
11081
  this.logger('warn', 'Failed to notify stop of noise cancellation', err);
11175
11082
  });
11176
11083
  };
11084
+ /**
11085
+ * Notifies the SFU about the mute state of the given track types.
11086
+ * @internal
11087
+ */
11088
+ this.notifyTrackMuteState = async (muted, ...trackTypes) => {
11089
+ if (!this.sfuClient)
11090
+ return;
11091
+ await this.sfuClient.updateMuteStates(trackTypes.map((trackType) => ({ trackType, muted })));
11092
+ };
11177
11093
  /**
11178
11094
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
11179
11095
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -11637,70 +11553,14 @@ class Call {
11637
11553
  *
11638
11554
  * @internal
11639
11555
  */
11640
- this.applyDeviceConfig = async (status) => {
11641
- await this.initCamera({ setStatus: status }).catch((err) => {
11556
+ this.applyDeviceConfig = async (settings, publish) => {
11557
+ await this.camera.apply(settings.video, publish).catch((err) => {
11642
11558
  this.logger('warn', 'Camera init failed', err);
11643
11559
  });
11644
- await this.initMic({ setStatus: status }).catch((err) => {
11560
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
11645
11561
  this.logger('warn', 'Mic init failed', err);
11646
11562
  });
11647
11563
  };
11648
- this.initCamera = async (options) => {
11649
- // Wait for any in progress camera operation
11650
- await this.camera.statusChangeSettled();
11651
- if (this.state.localParticipant?.videoStream ||
11652
- !this.permissionsContext.hasPermission('send-video')) {
11653
- return;
11654
- }
11655
- // Set camera direction if it's not yet set
11656
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
11657
- let defaultDirection = 'front';
11658
- const backendSetting = this.state.settings?.video.camera_facing;
11659
- if (backendSetting) {
11660
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
11661
- }
11662
- this.camera.state.setDirection(defaultDirection);
11663
- }
11664
- // Set target resolution
11665
- const targetResolution = this.state.settings?.video.target_resolution;
11666
- if (targetResolution) {
11667
- await this.camera.selectTargetResolution(targetResolution);
11668
- }
11669
- if (options.setStatus) {
11670
- // Publish already that was set before we joined
11671
- if (this.camera.enabled &&
11672
- this.camera.state.mediaStream &&
11673
- !this.publisher?.isPublishing(TrackType.VIDEO)) {
11674
- await this.publishVideoStream(this.camera.state.mediaStream);
11675
- }
11676
- // Start camera if backend config specifies, and there is no local setting
11677
- if (this.camera.state.status === undefined &&
11678
- this.state.settings?.video.camera_default_on) {
11679
- await this.camera.enable();
11680
- }
11681
- }
11682
- };
11683
- this.initMic = async (options) => {
11684
- // Wait for any in progress mic operation
11685
- await this.microphone.statusChangeSettled();
11686
- if (this.state.localParticipant?.audioStream ||
11687
- !this.permissionsContext.hasPermission('send-audio')) {
11688
- return;
11689
- }
11690
- if (options.setStatus) {
11691
- // Publish media stream that was set before we joined
11692
- if (this.microphone.enabled &&
11693
- this.microphone.state.mediaStream &&
11694
- !this.publisher?.isPublishing(TrackType.AUDIO)) {
11695
- await this.publishAudioStream(this.microphone.state.mediaStream);
11696
- }
11697
- // Start mic if backend config specifies, and there is no local setting
11698
- if (this.microphone.state.status === undefined &&
11699
- this.state.settings?.audio.mic_default_on) {
11700
- await this.microphone.enable();
11701
- }
11702
- }
11703
- };
11704
11564
  /**
11705
11565
  * Will begin tracking the given element for visibility changes within the
11706
11566
  * configured viewport element (`call.setViewport`).
@@ -11849,109 +11709,6 @@ class Call {
11849
11709
  this.screenShare = new ScreenShareManager(this);
11850
11710
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
11851
11711
  }
11852
- async setup() {
11853
- await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11854
- if (this.initialized)
11855
- return;
11856
- this.leaveCallHooks.add(this.on('all', (event) => {
11857
- // update state with the latest event data
11858
- this.state.updateFromEvent(event);
11859
- }));
11860
- this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
11861
- this.registerEffects();
11862
- this.registerReconnectHandlers();
11863
- if (this.state.callingState === CallingState.LEFT) {
11864
- this.state.setCallingState(CallingState.IDLE);
11865
- }
11866
- this.initialized = true;
11867
- });
11868
- }
11869
- registerEffects() {
11870
- this.leaveCallHooks.add(
11871
- // handles updating the permissions context when the settings change.
11872
- createSubscription(this.state.settings$, (settings) => {
11873
- if (!settings)
11874
- return;
11875
- this.permissionsContext.setCallSettings(settings);
11876
- }));
11877
- this.leaveCallHooks.add(
11878
- // handle the case when the user permissions are modified.
11879
- createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
11880
- this.leaveCallHooks.add(
11881
- // handles the case when the user is blocked by the call owner.
11882
- createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
11883
- if (!blockedUserIds || blockedUserIds.length === 0)
11884
- return;
11885
- const currentUserId = this.currentUserId;
11886
- if (currentUserId && blockedUserIds.includes(currentUserId)) {
11887
- this.logger('info', 'Leaving call because of being blocked');
11888
- await this.leave({ reason: 'user blocked' }).catch((err) => {
11889
- this.logger('error', 'Error leaving call after being blocked', err);
11890
- });
11891
- }
11892
- }));
11893
- this.leaveCallHooks.add(
11894
- // cancel auto-drop when call is
11895
- createSubscription(this.state.session$, (session) => {
11896
- if (!this.ringing)
11897
- return;
11898
- const receiverId = this.clientStore.connectedUser?.id;
11899
- if (!receiverId)
11900
- return;
11901
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
11902
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
11903
- if (isAcceptedByMe || isRejectedByMe) {
11904
- this.cancelAutoDrop();
11905
- }
11906
- }));
11907
- this.leaveCallHooks.add(
11908
- // "ringing" mode effects and event handlers
11909
- createSubscription(this.ringingSubject, (isRinging) => {
11910
- if (!isRinging)
11911
- return;
11912
- const callSession = this.state.session;
11913
- const receiver_id = this.clientStore.connectedUser?.id;
11914
- const ended_at = callSession?.ended_at;
11915
- const created_by_id = this.state.createdBy?.id;
11916
- const rejected_by = callSession?.rejected_by;
11917
- const accepted_by = callSession?.accepted_by;
11918
- let leaveCallIdle = false;
11919
- if (ended_at) {
11920
- // call was ended before it was accepted or rejected so we should leave it to idle
11921
- leaveCallIdle = true;
11922
- }
11923
- else if (created_by_id && rejected_by) {
11924
- if (rejected_by[created_by_id]) {
11925
- // call was cancelled by the caller
11926
- leaveCallIdle = true;
11927
- }
11928
- }
11929
- else if (receiver_id && rejected_by) {
11930
- if (rejected_by[receiver_id]) {
11931
- // call was rejected by the receiver in some other device
11932
- leaveCallIdle = true;
11933
- }
11934
- }
11935
- else if (receiver_id && accepted_by) {
11936
- if (accepted_by[receiver_id]) {
11937
- // call was accepted by the receiver in some other device
11938
- leaveCallIdle = true;
11939
- }
11940
- }
11941
- if (leaveCallIdle) {
11942
- if (this.state.callingState !== CallingState.IDLE) {
11943
- this.state.setCallingState(CallingState.IDLE);
11944
- }
11945
- }
11946
- else {
11947
- if (this.state.callingState === CallingState.IDLE) {
11948
- this.state.setCallingState(CallingState.RINGING);
11949
- }
11950
- this.scheduleAutoDrop();
11951
- this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
11952
- }
11953
- }));
11954
- }
11955
11712
  /**
11956
11713
  * A flag indicating whether the call is "ringing" type of call.
11957
11714
  */
@@ -11970,15 +11727,6 @@ class Call {
11970
11727
  get isCreatedByMe() {
11971
11728
  return this.state.createdBy?.id === this.currentUserId;
11972
11729
  }
11973
- /**
11974
- * Updates the preferred publishing options
11975
- *
11976
- * @internal
11977
- * @param options the options to use.
11978
- */
11979
- updatePublishOptions(options) {
11980
- this.publishOptions = { ...this.publishOptions, ...options };
11981
- }
11982
11730
  }
11983
11731
 
11984
11732
  var https = null;
@@ -13088,7 +12836,7 @@ class StreamClient {
13088
12836
  return await this.wsConnection.connect(this.defaultWSTimeout);
13089
12837
  };
13090
12838
  this.getUserAgent = () => {
13091
- const version = "1.14.0";
12839
+ const version = "1.15.0";
13092
12840
  return (this.userAgent ||
13093
12841
  `stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
13094
12842
  };
@@ -13388,7 +13136,7 @@ class StreamVideoClient {
13388
13136
  clientStore: this.writeableStateStore,
13389
13137
  });
13390
13138
  call.state.updateFromCallResponse(c.call);
13391
- await call.applyDeviceConfig(false);
13139
+ await call.applyDeviceConfig(c.call.settings, false);
13392
13140
  if (data.watch) {
13393
13141
  this.writeableStateStore.registerCall(call);
13394
13142
  }