@stream-io/video-client 1.13.1 → 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 (99) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +1704 -1762
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1706 -1780
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1704 -1762
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +61 -30
  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/coordinator/index.d.ts +904 -515
  15. package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
  16. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  17. package/dist/src/helpers/array.d.ts +7 -0
  18. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  20. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  21. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  22. package/dist/src/rtc/Publisher.d.ts +32 -86
  23. package/dist/src/rtc/Subscriber.d.ts +4 -56
  24. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  25. package/dist/src/rtc/codecs.d.ts +1 -15
  26. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  27. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  28. package/dist/src/rtc/index.d.ts +3 -0
  29. package/dist/src/rtc/videoLayers.d.ts +11 -25
  30. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  31. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  32. package/dist/src/stats/index.d.ts +1 -1
  33. package/dist/src/stats/types.d.ts +8 -0
  34. package/dist/src/store/CallState.d.ts +47 -5
  35. package/dist/src/store/rxUtils.d.ts +15 -1
  36. package/dist/src/types.d.ts +26 -22
  37. package/package.json +1 -1
  38. package/src/Call.ts +310 -271
  39. package/src/StreamSfuClient.ts +9 -14
  40. package/src/StreamVideoClient.ts +1 -1
  41. package/src/__tests__/Call.publishing.test.ts +306 -0
  42. package/src/devices/CameraManager.ts +33 -16
  43. package/src/devices/InputMediaDeviceManager.ts +36 -27
  44. package/src/devices/MicrophoneManager.ts +29 -8
  45. package/src/devices/ScreenShareManager.ts +6 -8
  46. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  47. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  48. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  50. package/src/devices/__tests__/mocks.ts +1 -0
  51. package/src/events/__tests__/internal.test.ts +132 -0
  52. package/src/events/__tests__/mutes.test.ts +0 -3
  53. package/src/events/__tests__/speaker.test.ts +92 -0
  54. package/src/events/participant.ts +3 -4
  55. package/src/gen/coordinator/index.ts +902 -514
  56. package/src/gen/video/sfu/event/events.ts +91 -30
  57. package/src/gen/video/sfu/models/models.ts +105 -13
  58. package/src/helpers/array.ts +14 -0
  59. package/src/permissions/PermissionsContext.ts +22 -0
  60. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  61. package/src/rpc/__tests__/createClient.test.ts +38 -0
  62. package/src/rpc/createClient.ts +11 -5
  63. package/src/rtc/BasePeerConnection.ts +240 -0
  64. package/src/rtc/Dispatcher.ts +0 -9
  65. package/src/rtc/IceTrickleBuffer.ts +24 -4
  66. package/src/rtc/Publisher.ts +210 -528
  67. package/src/rtc/Subscriber.ts +26 -200
  68. package/src/rtc/TransceiverCache.ts +120 -0
  69. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  70. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  71. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  72. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  73. package/src/rtc/codecs.ts +1 -131
  74. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  75. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  76. package/src/rtc/helpers/sdp.ts +30 -0
  77. package/src/rtc/helpers/tracks.ts +3 -0
  78. package/src/rtc/index.ts +4 -0
  79. package/src/rtc/videoLayers.ts +68 -76
  80. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  81. package/src/stats/SfuStatsReporter.ts +31 -3
  82. package/src/stats/index.ts +1 -1
  83. package/src/stats/types.ts +12 -0
  84. package/src/store/CallState.ts +115 -5
  85. package/src/store/__tests__/CallState.test.ts +101 -0
  86. package/src/store/rxUtils.ts +23 -1
  87. package/src/types.ts +27 -22
  88. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  89. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  90. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  91. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  92. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  93. package/src/helpers/sdp-munging.ts +0 -265
  94. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  95. package/src/rtc/__tests__/codecs.test.ts +0 -145
  96. package/src/rtc/bitrateLookup.ts +0 -61
  97. package/src/rtc/helpers/iceCandidate.ts +0 -16
  98. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  99. /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
package/dist/index.cjs.js CHANGED
@@ -5,30 +5,11 @@ var runtime = require('@protobuf-ts/runtime');
5
5
  var runtimeRpc = require('@protobuf-ts/runtime-rpc');
6
6
  var axios = require('axios');
7
7
  var twirpTransport = require('@protobuf-ts/twirp-transport');
8
- var uaParserJs = require('ua-parser-js');
9
8
  var rxjs = require('rxjs');
10
- var SDP = require('sdp-transform');
9
+ var sdpTransform = require('sdp-transform');
10
+ var uaParserJs = require('ua-parser-js');
11
11
  var https = require('https');
12
12
 
13
- function _interopNamespaceDefault(e) {
14
- var n = Object.create(null);
15
- if (e) {
16
- Object.keys(e).forEach(function (k) {
17
- if (k !== 'default') {
18
- var d = Object.getOwnPropertyDescriptor(e, k);
19
- Object.defineProperty(n, k, d.get ? d : {
20
- enumerable: true,
21
- get: function () { return e[k]; }
22
- });
23
- }
24
- });
25
- }
26
- n.default = e;
27
- return Object.freeze(n);
28
- }
29
-
30
- var SDP__namespace = /*#__PURE__*/_interopNamespaceDefault(SDP);
31
-
32
13
  /* tslint:disable */
33
14
  /* eslint-disable */
34
15
  /**
@@ -77,6 +58,48 @@ const ChannelConfigWithInfoBlocklistBehaviorEnum = {
77
58
  BLOCK: 'block',
78
59
  SHADOW_BLOCK: 'shadow_block',
79
60
  };
61
+ /**
62
+ * All possibility of string to use
63
+ * @export
64
+ */
65
+ const ChannelOwnCapability = {
66
+ BAN_CHANNEL_MEMBERS: 'ban-channel-members',
67
+ CAST_POLL_VOTE: 'cast-poll-vote',
68
+ CONNECT_EVENTS: 'connect-events',
69
+ CREATE_ATTACHMENT: 'create-attachment',
70
+ CREATE_CALL: 'create-call',
71
+ DELETE_ANY_MESSAGE: 'delete-any-message',
72
+ DELETE_CHANNEL: 'delete-channel',
73
+ DELETE_OWN_MESSAGE: 'delete-own-message',
74
+ FLAG_MESSAGE: 'flag-message',
75
+ FREEZE_CHANNEL: 'freeze-channel',
76
+ JOIN_CALL: 'join-call',
77
+ JOIN_CHANNEL: 'join-channel',
78
+ LEAVE_CHANNEL: 'leave-channel',
79
+ MUTE_CHANNEL: 'mute-channel',
80
+ PIN_MESSAGE: 'pin-message',
81
+ QUERY_POLL_VOTES: 'query-poll-votes',
82
+ QUOTE_MESSAGE: 'quote-message',
83
+ READ_EVENTS: 'read-events',
84
+ SEARCH_MESSAGES: 'search-messages',
85
+ SEND_CUSTOM_EVENTS: 'send-custom-events',
86
+ SEND_LINKS: 'send-links',
87
+ SEND_MESSAGE: 'send-message',
88
+ SEND_POLL: 'send-poll',
89
+ SEND_REACTION: 'send-reaction',
90
+ SEND_REPLY: 'send-reply',
91
+ SEND_TYPING_EVENTS: 'send-typing-events',
92
+ SET_CHANNEL_COOLDOWN: 'set-channel-cooldown',
93
+ SKIP_SLOW_MODE: 'skip-slow-mode',
94
+ SLOW_MODE: 'slow-mode',
95
+ TYPING_EVENTS: 'typing-events',
96
+ UPDATE_ANY_MESSAGE: 'update-any-message',
97
+ UPDATE_CHANNEL: 'update-channel',
98
+ UPDATE_CHANNEL_MEMBERS: 'update-channel-members',
99
+ UPDATE_OWN_MESSAGE: 'update-own-message',
100
+ UPDATE_THREAD: 'update-thread',
101
+ UPLOAD_FILE: 'upload-file',
102
+ };
80
103
  /**
81
104
  * @export
82
105
  */
@@ -116,9 +139,11 @@ const OwnCapability = {
116
139
  SEND_AUDIO: 'send-audio',
117
140
  SEND_VIDEO: 'send-video',
118
141
  START_BROADCAST_CALL: 'start-broadcast-call',
142
+ START_CLOSED_CAPTIONS_CALL: 'start-closed-captions-call',
119
143
  START_RECORD_CALL: 'start-record-call',
120
144
  START_TRANSCRIPTION_CALL: 'start-transcription-call',
121
145
  STOP_BROADCAST_CALL: 'stop-broadcast-call',
146
+ STOP_CLOSED_CAPTIONS_CALL: 'stop-closed-captions-call',
122
147
  STOP_RECORD_CALL: 'stop-record-call',
123
148
  STOP_TRANSCRIPTION_CALL: 'stop-transcription-call',
124
149
  UPDATE_CALL: 'update-call',
@@ -149,6 +174,14 @@ const RecordSettingsRequestQualityEnum = {
149
174
  PORTRAIT_1080X1920: 'portrait-1080x1920',
150
175
  PORTRAIT_1440X2560: 'portrait-1440x2560',
151
176
  };
177
+ /**
178
+ * @export
179
+ */
180
+ const TranscriptionSettingsRequestClosedCaptionModeEnum = {
181
+ AVAILABLE: 'available',
182
+ DISABLED: 'disabled',
183
+ AUTO_ON: 'auto-on',
184
+ };
152
185
  /**
153
186
  * @export
154
187
  */
@@ -157,6 +190,14 @@ const TranscriptionSettingsRequestModeEnum = {
157
190
  DISABLED: 'disabled',
158
191
  AUTO_ON: 'auto-on',
159
192
  };
193
+ /**
194
+ * @export
195
+ */
196
+ const TranscriptionSettingsResponseClosedCaptionModeEnum = {
197
+ AVAILABLE: 'available',
198
+ DISABLED: 'disabled',
199
+ AUTO_ON: 'auto-on',
200
+ };
160
201
  /**
161
202
  * @export
162
203
  */
@@ -1185,23 +1226,33 @@ class VideoLayer$Type extends runtime.MessageType {
1185
1226
  */
1186
1227
  const VideoLayer = new VideoLayer$Type();
1187
1228
  // @generated message type with reflection information, may provide speed optimized methods
1188
- class PublishOptions$Type extends runtime.MessageType {
1229
+ class SubscribeOption$Type extends runtime.MessageType {
1189
1230
  constructor() {
1190
- super('stream.video.sfu.models.PublishOptions', [
1231
+ super('stream.video.sfu.models.SubscribeOption', [
1191
1232
  {
1192
1233
  no: 1,
1234
+ name: 'track_type',
1235
+ kind: 'enum',
1236
+ T: () => [
1237
+ 'stream.video.sfu.models.TrackType',
1238
+ TrackType,
1239
+ 'TRACK_TYPE_',
1240
+ ],
1241
+ },
1242
+ {
1243
+ no: 2,
1193
1244
  name: 'codecs',
1194
1245
  kind: 'message',
1195
1246
  repeat: 1 /*RepeatType.PACKED*/,
1196
- T: () => PublishOption,
1247
+ T: () => Codec,
1197
1248
  },
1198
1249
  ]);
1199
1250
  }
1200
1251
  }
1201
1252
  /**
1202
- * @generated MessageType for protobuf message stream.video.sfu.models.PublishOptions
1253
+ * @generated MessageType for protobuf message stream.video.sfu.models.SubscribeOption
1203
1254
  */
1204
- const PublishOptions = new PublishOptions$Type();
1255
+ const SubscribeOption = new SubscribeOption$Type();
1205
1256
  // @generated message type with reflection information, may provide speed optimized methods
1206
1257
  class PublishOption$Type extends runtime.MessageType {
1207
1258
  constructor() {
@@ -1231,6 +1282,13 @@ class PublishOption$Type extends runtime.MessageType {
1231
1282
  kind: 'scalar',
1232
1283
  T: 5 /*ScalarType.INT32*/,
1233
1284
  },
1285
+ {
1286
+ no: 7,
1287
+ name: 'video_dimension',
1288
+ kind: 'message',
1289
+ T: () => VideoDimension,
1290
+ },
1291
+ { no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
1234
1292
  ]);
1235
1293
  }
1236
1294
  }
@@ -1243,7 +1301,7 @@ class Codec$Type extends runtime.MessageType {
1243
1301
  constructor() {
1244
1302
  super('stream.video.sfu.models.Codec', [
1245
1303
  {
1246
- no: 11,
1304
+ no: 16,
1247
1305
  name: 'payload_type',
1248
1306
  kind: 'scalar',
1249
1307
  T: 13 /*ScalarType.UINT32*/,
@@ -1256,7 +1314,7 @@ class Codec$Type extends runtime.MessageType {
1256
1314
  T: 13 /*ScalarType.UINT32*/,
1257
1315
  },
1258
1316
  {
1259
- no: 13,
1317
+ no: 15,
1260
1318
  name: 'encoding_parameters',
1261
1319
  kind: 'scalar',
1262
1320
  T: 9 /*ScalarType.STRING*/,
@@ -1320,6 +1378,13 @@ class TrackInfo$Type extends runtime.MessageType {
1320
1378
  { no: 8, name: 'stereo', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1321
1379
  { no: 9, name: 'red', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1322
1380
  { no: 10, name: 'muted', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1381
+ { no: 11, name: 'codec', kind: 'message', T: () => Codec },
1382
+ {
1383
+ no: 12,
1384
+ name: 'publish_option_id',
1385
+ kind: 'scalar',
1386
+ T: 5 /*ScalarType.INT32*/,
1387
+ },
1323
1388
  ]);
1324
1389
  }
1325
1390
  }
@@ -1593,10 +1658,10 @@ var models = /*#__PURE__*/Object.freeze({
1593
1658
  get PeerType () { return PeerType; },
1594
1659
  Pin: Pin,
1595
1660
  PublishOption: PublishOption,
1596
- PublishOptions: PublishOptions,
1597
1661
  Sdk: Sdk,
1598
1662
  get SdkType () { return SdkType; },
1599
1663
  StreamQuality: StreamQuality,
1664
+ SubscribeOption: SubscribeOption,
1600
1665
  TrackInfo: TrackInfo,
1601
1666
  get TrackType () { return TrackType; },
1602
1667
  get TrackUnpublishReason () { return TrackUnpublishReason; },
@@ -2226,13 +2291,6 @@ class SfuEvent$Type extends runtime.MessageType {
2226
2291
  oneof: 'eventPayload',
2227
2292
  T: () => ParticipantMigrationComplete,
2228
2293
  },
2229
- {
2230
- no: 26,
2231
- name: 'codec_negotiation_complete',
2232
- kind: 'message',
2233
- oneof: 'eventPayload',
2234
- T: () => CodecNegotiationComplete,
2235
- },
2236
2294
  {
2237
2295
  no: 27,
2238
2296
  name: 'change_publish_options',
@@ -2253,10 +2311,12 @@ class ChangePublishOptions$Type extends runtime.MessageType {
2253
2311
  super('stream.video.sfu.event.ChangePublishOptions', [
2254
2312
  {
2255
2313
  no: 1,
2256
- name: 'publish_option',
2314
+ name: 'publish_options',
2257
2315
  kind: 'message',
2316
+ repeat: 1 /*RepeatType.PACKED*/,
2258
2317
  T: () => PublishOption,
2259
2318
  },
2319
+ { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2260
2320
  ]);
2261
2321
  }
2262
2322
  }
@@ -2265,15 +2325,15 @@ class ChangePublishOptions$Type extends runtime.MessageType {
2265
2325
  */
2266
2326
  const ChangePublishOptions = new ChangePublishOptions$Type();
2267
2327
  // @generated message type with reflection information, may provide speed optimized methods
2268
- class CodecNegotiationComplete$Type extends runtime.MessageType {
2328
+ class ChangePublishOptionsComplete$Type extends runtime.MessageType {
2269
2329
  constructor() {
2270
- super('stream.video.sfu.event.CodecNegotiationComplete', []);
2330
+ super('stream.video.sfu.event.ChangePublishOptionsComplete', []);
2271
2331
  }
2272
2332
  }
2273
2333
  /**
2274
- * @generated MessageType for protobuf message stream.video.sfu.event.CodecNegotiationComplete
2334
+ * @generated MessageType for protobuf message stream.video.sfu.event.ChangePublishOptionsComplete
2275
2335
  */
2276
- const CodecNegotiationComplete = new CodecNegotiationComplete$Type();
2336
+ const ChangePublishOptionsComplete = new ChangePublishOptionsComplete$Type();
2277
2337
  // @generated message type with reflection information, may provide speed optimized methods
2278
2338
  class ParticipantMigrationComplete$Type extends runtime.MessageType {
2279
2339
  constructor() {
@@ -2531,6 +2591,20 @@ class JoinRequest$Type extends runtime.MessageType {
2531
2591
  kind: 'message',
2532
2592
  T: () => ReconnectDetails,
2533
2593
  },
2594
+ {
2595
+ no: 9,
2596
+ name: 'preferred_publish_options',
2597
+ kind: 'message',
2598
+ repeat: 1 /*RepeatType.PACKED*/,
2599
+ T: () => PublishOption,
2600
+ },
2601
+ {
2602
+ no: 10,
2603
+ name: 'preferred_subscribe_options',
2604
+ kind: 'message',
2605
+ repeat: 1 /*RepeatType.PACKED*/,
2606
+ T: () => SubscribeOption,
2607
+ },
2534
2608
  ]);
2535
2609
  }
2536
2610
  }
@@ -2638,7 +2712,8 @@ class JoinResponse$Type extends runtime.MessageType {
2638
2712
  no: 4,
2639
2713
  name: 'publish_options',
2640
2714
  kind: 'message',
2641
- T: () => PublishOptions,
2715
+ repeat: 1 /*RepeatType.PACKED*/,
2716
+ T: () => PublishOption,
2642
2717
  },
2643
2718
  ]);
2644
2719
  }
@@ -2803,6 +2878,22 @@ class AudioSender$Type extends runtime.MessageType {
2803
2878
  constructor() {
2804
2879
  super('stream.video.sfu.event.AudioSender', [
2805
2880
  { no: 2, name: 'codec', kind: 'message', T: () => Codec },
2881
+ {
2882
+ no: 3,
2883
+ name: 'track_type',
2884
+ kind: 'enum',
2885
+ T: () => [
2886
+ 'stream.video.sfu.models.TrackType',
2887
+ TrackType,
2888
+ 'TRACK_TYPE_',
2889
+ ],
2890
+ },
2891
+ {
2892
+ no: 4,
2893
+ name: 'publish_option_id',
2894
+ kind: 'scalar',
2895
+ T: 5 /*ScalarType.INT32*/,
2896
+ },
2806
2897
  ]);
2807
2898
  }
2808
2899
  }
@@ -2855,6 +2946,22 @@ class VideoSender$Type extends runtime.MessageType {
2855
2946
  repeat: 1 /*RepeatType.PACKED*/,
2856
2947
  T: () => VideoLayerSetting,
2857
2948
  },
2949
+ {
2950
+ no: 4,
2951
+ name: 'track_type',
2952
+ kind: 'enum',
2953
+ T: () => [
2954
+ 'stream.video.sfu.models.TrackType',
2955
+ TrackType,
2956
+ 'TRACK_TYPE_',
2957
+ ],
2958
+ },
2959
+ {
2960
+ no: 5,
2961
+ name: 'publish_option_id',
2962
+ kind: 'scalar',
2963
+ T: 5 /*ScalarType.INT32*/,
2964
+ },
2858
2965
  ]);
2859
2966
  }
2860
2967
  }
@@ -2951,8 +3058,8 @@ var events = /*#__PURE__*/Object.freeze({
2951
3058
  CallEnded: CallEnded,
2952
3059
  CallGrantsUpdated: CallGrantsUpdated,
2953
3060
  ChangePublishOptions: ChangePublishOptions,
3061
+ ChangePublishOptionsComplete: ChangePublishOptionsComplete,
2954
3062
  ChangePublishQuality: ChangePublishQuality,
2955
- CodecNegotiationComplete: CodecNegotiationComplete,
2956
3063
  ConnectionQualityChanged: ConnectionQualityChanged,
2957
3064
  ConnectionQualityInfo: ConnectionQualityInfo,
2958
3065
  DominantSpeakerChanged: DominantSpeakerChanged,
@@ -3099,11 +3206,18 @@ const withHeaders = (headers) => {
3099
3206
  const withRequestLogger = (logger, level) => {
3100
3207
  return {
3101
3208
  interceptUnary: (next, method, input, options) => {
3102
- logger(level, `Calling SFU RPC method ${method.name}`, {
3103
- input,
3104
- options,
3105
- });
3106
- return next(method, input, options);
3209
+ let invocation;
3210
+ try {
3211
+ invocation = next(method, input, options);
3212
+ }
3213
+ finally {
3214
+ logger(level, `Invoked SFU RPC method ${method.name}`, {
3215
+ request: invocation?.request,
3216
+ headers: invocation?.requestHeaders,
3217
+ response: invocation?.response,
3218
+ });
3219
+ }
3220
+ return invocation;
3107
3221
  },
3108
3222
  };
3109
3223
  };
@@ -3320,374 +3434,98 @@ const retryable = async (rpc, signal) => {
3320
3434
  return result;
3321
3435
  };
3322
3436
 
3323
- const version = "1.13.1";
3324
- const [major, minor, patch] = version.split('.');
3325
- let sdkInfo = {
3326
- type: SdkType.PLAIN_JAVASCRIPT,
3327
- major,
3328
- minor,
3329
- patch,
3330
- };
3331
- let osInfo;
3332
- let deviceInfo;
3333
- let webRtcInfo;
3334
- let deviceState = { oneofKind: undefined };
3335
- const setSdkInfo = (info) => {
3336
- sdkInfo = info;
3337
- };
3338
- const getSdkInfo = () => {
3339
- return sdkInfo;
3340
- };
3341
- const setOSInfo = (info) => {
3342
- osInfo = info;
3343
- };
3344
- const getOSInfo = () => {
3345
- return osInfo;
3346
- };
3347
- const setDeviceInfo = (info) => {
3348
- deviceInfo = info;
3437
+ /**
3438
+ * Returns a generic SDP for the given direction.
3439
+ * We use this SDP to send it as part of our JoinRequest so that the SFU
3440
+ * can use it to determine the client's codec capabilities.
3441
+ *
3442
+ * @param direction the direction of the transceiver.
3443
+ */
3444
+ const getGenericSdp = async (direction) => {
3445
+ const tempPc = new RTCPeerConnection();
3446
+ tempPc.addTransceiver('video', { direction });
3447
+ tempPc.addTransceiver('audio', { direction });
3448
+ const offer = await tempPc.createOffer();
3449
+ const sdp = offer.sdp ?? '';
3450
+ tempPc.getTransceivers().forEach((t) => {
3451
+ t.stop?.();
3452
+ });
3453
+ tempPc.close();
3454
+ return sdp;
3349
3455
  };
3350
- const getDeviceInfo = () => {
3351
- return deviceInfo;
3456
+ /**
3457
+ * Returns whether the codec is an SVC codec.
3458
+ *
3459
+ * @param codecOrMimeType the codec to check.
3460
+ */
3461
+ const isSvcCodec = (codecOrMimeType) => {
3462
+ if (!codecOrMimeType)
3463
+ return false;
3464
+ codecOrMimeType = codecOrMimeType.toLowerCase();
3465
+ return (codecOrMimeType === 'vp9' ||
3466
+ codecOrMimeType === 'av1' ||
3467
+ codecOrMimeType === 'video/vp9' ||
3468
+ codecOrMimeType === 'video/av1');
3352
3469
  };
3353
- const getWebRTCInfo = () => {
3354
- return webRtcInfo;
3470
+
3471
+ const sfuEventKinds = {
3472
+ subscriberOffer: undefined,
3473
+ publisherAnswer: undefined,
3474
+ connectionQualityChanged: undefined,
3475
+ audioLevelChanged: undefined,
3476
+ iceTrickle: undefined,
3477
+ changePublishQuality: undefined,
3478
+ participantJoined: undefined,
3479
+ participantLeft: undefined,
3480
+ dominantSpeakerChanged: undefined,
3481
+ joinResponse: undefined,
3482
+ healthCheckResponse: undefined,
3483
+ trackPublished: undefined,
3484
+ trackUnpublished: undefined,
3485
+ error: undefined,
3486
+ callGrantsUpdated: undefined,
3487
+ goAway: undefined,
3488
+ iceRestart: undefined,
3489
+ pinsUpdated: undefined,
3490
+ callEnded: undefined,
3491
+ participantUpdated: undefined,
3492
+ participantMigrationComplete: undefined,
3493
+ changePublishOptions: undefined,
3355
3494
  };
3356
- const setWebRTCInfo = (info) => {
3357
- webRtcInfo = info;
3495
+ const isSfuEvent = (eventName) => {
3496
+ return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3358
3497
  };
3359
- const setThermalState = (state) => {
3360
- if (!osInfo) {
3361
- deviceState = { oneofKind: undefined };
3362
- return;
3363
- }
3364
- if (osInfo.name === 'android') {
3365
- const thermalState = AndroidThermalState[state] ||
3366
- AndroidThermalState.UNSPECIFIED;
3367
- deviceState = {
3368
- oneofKind: 'android',
3369
- android: {
3370
- thermalState,
3371
- isPowerSaverMode: deviceState?.oneofKind === 'android' &&
3372
- deviceState.android.isPowerSaverMode,
3373
- },
3498
+ class Dispatcher {
3499
+ constructor() {
3500
+ this.logger = getLogger(['Dispatcher']);
3501
+ this.subscribers = {};
3502
+ this.dispatch = (message, logTag = '0') => {
3503
+ const eventKind = message.eventPayload.oneofKind;
3504
+ if (!eventKind)
3505
+ return;
3506
+ const payload = message.eventPayload[eventKind];
3507
+ this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3508
+ const listeners = this.subscribers[eventKind];
3509
+ if (!listeners)
3510
+ return;
3511
+ for (const fn of listeners) {
3512
+ try {
3513
+ fn(payload);
3514
+ }
3515
+ catch (e) {
3516
+ this.logger('warn', 'Listener failed with error', e);
3517
+ }
3518
+ }
3374
3519
  };
3375
- }
3376
- if (osInfo.name.toLowerCase() === 'ios') {
3377
- const thermalState = AppleThermalState[state] ||
3378
- AppleThermalState.UNSPECIFIED;
3379
- deviceState = {
3380
- oneofKind: 'apple',
3381
- apple: {
3382
- thermalState,
3383
- isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
3384
- deviceState.apple.isLowPowerModeEnabled,
3385
- },
3386
- };
3387
- }
3388
- };
3389
- const setPowerState = (powerMode) => {
3390
- if (!osInfo) {
3391
- deviceState = { oneofKind: undefined };
3392
- return;
3393
- }
3394
- if (osInfo.name === 'android') {
3395
- deviceState = {
3396
- oneofKind: 'android',
3397
- android: {
3398
- thermalState: deviceState?.oneofKind === 'android'
3399
- ? deviceState.android.thermalState
3400
- : AndroidThermalState.UNSPECIFIED,
3401
- isPowerSaverMode: powerMode,
3402
- },
3403
- };
3404
- }
3405
- if (osInfo.name.toLowerCase() === 'ios') {
3406
- deviceState = {
3407
- oneofKind: 'apple',
3408
- apple: {
3409
- thermalState: deviceState?.oneofKind === 'apple'
3410
- ? deviceState.apple.thermalState
3411
- : AppleThermalState.UNSPECIFIED,
3412
- isLowPowerModeEnabled: powerMode,
3413
- },
3414
- };
3415
- }
3416
- };
3417
- const getDeviceState = () => {
3418
- return deviceState;
3419
- };
3420
- const getClientDetails = () => {
3421
- if (isReactNative()) {
3422
- // Since RN doesn't support web, sharing browser info is not required
3423
- return {
3424
- sdk: getSdkInfo(),
3425
- os: getOSInfo(),
3426
- device: getDeviceInfo(),
3427
- };
3428
- }
3429
- const userAgent = new uaParserJs.UAParser(navigator.userAgent);
3430
- const { browser, os, device, cpu } = userAgent.getResult();
3431
- return {
3432
- sdk: getSdkInfo(),
3433
- browser: {
3434
- name: browser.name || navigator.userAgent,
3435
- version: browser.version || '',
3436
- },
3437
- os: {
3438
- name: os.name || '',
3439
- version: os.version || '',
3440
- architecture: cpu.architecture || '',
3441
- },
3442
- device: {
3443
- name: [device.vendor, device.model, device.type]
3444
- .filter(Boolean)
3445
- .join(' '),
3446
- version: '',
3447
- },
3448
- };
3449
- };
3450
-
3451
- /**
3452
- * Checks whether the current browser is Safari.
3453
- */
3454
- const isSafari = () => {
3455
- if (typeof navigator === 'undefined')
3456
- return false;
3457
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
3458
- };
3459
- /**
3460
- * Checks whether the current browser is Firefox.
3461
- */
3462
- const isFirefox = () => {
3463
- if (typeof navigator === 'undefined')
3464
- return false;
3465
- return navigator.userAgent?.includes('Firefox');
3466
- };
3467
- /**
3468
- * Checks whether the current browser is Google Chrome.
3469
- */
3470
- const isChrome = () => {
3471
- if (typeof navigator === 'undefined')
3472
- return false;
3473
- return navigator.userAgent?.includes('Chrome');
3474
- };
3475
-
3476
- var browsers = /*#__PURE__*/Object.freeze({
3477
- __proto__: null,
3478
- isChrome: isChrome,
3479
- isFirefox: isFirefox,
3480
- isSafari: isSafari
3481
- });
3482
-
3483
- /**
3484
- * Returns back a list of sorted codecs, with the preferred codec first.
3485
- *
3486
- * @param kind the kind of codec to get.
3487
- * @param preferredCodec the codec to prioritize (vp8, h264, vp9, av1...).
3488
- * @param codecToRemove the codec to exclude from the list.
3489
- * @param codecPreferencesSource the source of the codec preferences.
3490
- */
3491
- const getPreferredCodecs = (kind, preferredCodec, codecToRemove, codecPreferencesSource) => {
3492
- const source = codecPreferencesSource === 'receiver' ? RTCRtpReceiver : RTCRtpSender;
3493
- if (!('getCapabilities' in source))
3494
- return;
3495
- const capabilities = source.getCapabilities(kind);
3496
- if (!capabilities)
3497
- return;
3498
- const preferred = [];
3499
- const partiallyPreferred = [];
3500
- const unpreferred = [];
3501
- const preferredCodecMimeType = `${kind}/${preferredCodec.toLowerCase()}`;
3502
- const codecToRemoveMimeType = codecToRemove && `${kind}/${codecToRemove.toLowerCase()}`;
3503
- for (const codec of capabilities.codecs) {
3504
- const codecMimeType = codec.mimeType.toLowerCase();
3505
- const shouldRemoveCodec = codecMimeType === codecToRemoveMimeType;
3506
- if (shouldRemoveCodec)
3507
- continue; // skip this codec
3508
- const isPreferredCodec = codecMimeType === preferredCodecMimeType;
3509
- if (!isPreferredCodec) {
3510
- unpreferred.push(codec);
3511
- continue;
3512
- }
3513
- // h264 is a special case, we want to prioritize the baseline codec with
3514
- // profile-level-id is 42e01f and packetization-mode=0 for maximum
3515
- // cross-browser compatibility.
3516
- // this branch covers the other cases, such as vp8.
3517
- if (codecMimeType !== 'video/h264') {
3518
- preferred.push(codec);
3519
- continue;
3520
- }
3521
- const sdpFmtpLine = codec.sdpFmtpLine;
3522
- if (!sdpFmtpLine || !sdpFmtpLine.includes('profile-level-id=42')) {
3523
- // this is not the baseline h264 codec, prioritize it lower
3524
- partiallyPreferred.push(codec);
3525
- continue;
3526
- }
3527
- if (sdpFmtpLine.includes('packetization-mode=1')) {
3528
- preferred.unshift(codec);
3529
- }
3530
- else {
3531
- preferred.push(codec);
3532
- }
3533
- }
3534
- // return a sorted list of codecs, with the preferred codecs first
3535
- return [...preferred, ...partiallyPreferred, ...unpreferred];
3536
- };
3537
- /**
3538
- * Returns a generic SDP for the given direction.
3539
- * We use this SDP to send it as part of our JoinRequest so that the SFU
3540
- * can use it to determine client's codec capabilities.
3541
- *
3542
- * @param direction the direction of the transceiver.
3543
- */
3544
- const getGenericSdp = async (direction) => {
3545
- const tempPc = new RTCPeerConnection();
3546
- tempPc.addTransceiver('video', { direction });
3547
- tempPc.addTransceiver('audio', { direction });
3548
- const offer = await tempPc.createOffer();
3549
- const sdp = offer.sdp ?? '';
3550
- tempPc.getTransceivers().forEach((t) => {
3551
- t.stop?.();
3552
- });
3553
- tempPc.close();
3554
- return sdp;
3555
- };
3556
- /**
3557
- * Returns the optimal video codec for the device.
3558
- */
3559
- const getOptimalVideoCodec = (preferredCodec) => {
3560
- if (isReactNative()) {
3561
- const os = getOSInfo()?.name.toLowerCase();
3562
- if (os === 'android')
3563
- return preferredOr(preferredCodec, 'vp8');
3564
- if (os === 'ios' || os === 'ipados') {
3565
- return supportsH264Baseline() ? 'h264' : 'vp8';
3566
- }
3567
- return preferredOr(preferredCodec, 'h264');
3568
- }
3569
- if (isSafari())
3570
- return 'h264';
3571
- if (isFirefox())
3572
- return 'vp8';
3573
- return preferredOr(preferredCodec, 'vp8');
3574
- };
3575
- /**
3576
- * Determines if the platform supports the preferred codec.
3577
- * If not, it returns the fallback codec.
3578
- */
3579
- const preferredOr = (codec, fallback) => {
3580
- if (!codec)
3581
- return fallback;
3582
- if (!('getCapabilities' in RTCRtpSender))
3583
- return fallback;
3584
- const capabilities = RTCRtpSender.getCapabilities('video');
3585
- if (!capabilities)
3586
- return fallback;
3587
- // Safari and Firefox do not have a good support encoding to SVC codecs,
3588
- // so we disable it for them.
3589
- if (isSvcCodec(codec) && (isSafari() || isFirefox()))
3590
- return fallback;
3591
- const { codecs } = capabilities;
3592
- const codecMimeType = `video/${codec}`.toLowerCase();
3593
- return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
3594
- ? codec
3595
- : fallback;
3596
- };
3597
- /**
3598
- * Returns whether the platform supports the H264 baseline codec.
3599
- */
3600
- const supportsH264Baseline = () => {
3601
- if (!('getCapabilities' in RTCRtpSender))
3602
- return false;
3603
- const capabilities = RTCRtpSender.getCapabilities('video');
3604
- if (!capabilities)
3605
- return false;
3606
- return capabilities.codecs.some((c) => c.mimeType.toLowerCase() === 'video/h264' &&
3607
- c.sdpFmtpLine?.includes('profile-level-id=42e01f'));
3608
- };
3609
- /**
3610
- * Returns whether the codec is an SVC codec.
3611
- *
3612
- * @param codecOrMimeType the codec to check.
3613
- */
3614
- const isSvcCodec = (codecOrMimeType) => {
3615
- if (!codecOrMimeType)
3616
- return false;
3617
- codecOrMimeType = codecOrMimeType.toLowerCase();
3618
- return (codecOrMimeType === 'vp9' ||
3619
- codecOrMimeType === 'av1' ||
3620
- codecOrMimeType === 'video/vp9' ||
3621
- codecOrMimeType === 'video/av1');
3622
- };
3623
-
3624
- const sfuEventKinds = {
3625
- subscriberOffer: undefined,
3626
- publisherAnswer: undefined,
3627
- connectionQualityChanged: undefined,
3628
- audioLevelChanged: undefined,
3629
- iceTrickle: undefined,
3630
- changePublishQuality: undefined,
3631
- participantJoined: undefined,
3632
- participantLeft: undefined,
3633
- dominantSpeakerChanged: undefined,
3634
- joinResponse: undefined,
3635
- healthCheckResponse: undefined,
3636
- trackPublished: undefined,
3637
- trackUnpublished: undefined,
3638
- error: undefined,
3639
- callGrantsUpdated: undefined,
3640
- goAway: undefined,
3641
- iceRestart: undefined,
3642
- pinsUpdated: undefined,
3643
- callEnded: undefined,
3644
- participantUpdated: undefined,
3645
- participantMigrationComplete: undefined,
3646
- codecNegotiationComplete: undefined,
3647
- changePublishOptions: undefined,
3648
- };
3649
- const isSfuEvent = (eventName) => {
3650
- return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3651
- };
3652
- class Dispatcher {
3653
- constructor() {
3654
- this.logger = getLogger(['Dispatcher']);
3655
- this.subscribers = {};
3656
- this.dispatch = (message, logTag = '0') => {
3657
- const eventKind = message.eventPayload.oneofKind;
3658
- if (!eventKind)
3659
- return;
3660
- const payload = message.eventPayload[eventKind];
3661
- this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3662
- const listeners = this.subscribers[eventKind];
3663
- if (!listeners)
3664
- return;
3665
- for (const fn of listeners) {
3666
- try {
3667
- fn(payload);
3668
- }
3669
- catch (e) {
3670
- this.logger('warn', 'Listener failed with error', e);
3671
- }
3672
- }
3673
- };
3674
- this.on = (eventName, fn) => {
3675
- var _a;
3676
- ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3677
- return () => {
3678
- this.off(eventName, fn);
3679
- };
3680
- };
3681
- this.off = (eventName, fn) => {
3682
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3683
- };
3684
- this.offAll = (eventName) => {
3685
- if (eventName) {
3686
- this.subscribers[eventName] = [];
3687
- }
3688
- else {
3689
- this.subscribers = {};
3690
- }
3520
+ this.on = (eventName, fn) => {
3521
+ var _a;
3522
+ ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3523
+ return () => {
3524
+ this.off(eventName, fn);
3525
+ };
3526
+ };
3527
+ this.off = (eventName, fn) => {
3528
+ this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3691
3529
  };
3692
3530
  }
3693
3531
  }
@@ -3701,284 +3539,34 @@ class IceTrickleBuffer {
3701
3539
  this.subscriberCandidates = new rxjs.ReplaySubject();
3702
3540
  this.publisherCandidates = new rxjs.ReplaySubject();
3703
3541
  this.push = (iceTrickle) => {
3542
+ const iceCandidate = toIceCandidate(iceTrickle);
3543
+ if (!iceCandidate)
3544
+ return;
3704
3545
  if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
3705
- this.subscriberCandidates.next(iceTrickle);
3546
+ this.subscriberCandidates.next(iceCandidate);
3706
3547
  }
3707
3548
  else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
3708
- this.publisherCandidates.next(iceTrickle);
3549
+ this.publisherCandidates.next(iceCandidate);
3709
3550
  }
3710
3551
  else {
3711
3552
  const logger = getLogger(['sfu-client']);
3712
3553
  logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
3713
3554
  }
3714
3555
  };
3715
- }
3716
- }
3717
-
3718
- function getIceCandidate(candidate) {
3719
- if (!candidate.usernameFragment) {
3720
- // react-native-webrtc doesn't include usernameFragment in the candidate
3721
- const splittedCandidate = candidate.candidate.split(' ');
3722
- const ufragIndex = splittedCandidate.findIndex((s) => s === 'ufrag') + 1;
3723
- const usernameFragment = splittedCandidate[ufragIndex];
3724
- return JSON.stringify({ ...candidate, usernameFragment });
3725
- }
3726
- else {
3727
- return JSON.stringify(candidate.toJSON());
3728
- }
3729
- }
3730
-
3731
- const bitrateLookupTable = {
3732
- h264: {
3733
- 2160: 5000000,
3734
- 1440: 3000000,
3735
- 1080: 2000000,
3736
- 720: 1250000,
3737
- 540: 750000,
3738
- 360: 400000,
3739
- default: 1250000,
3740
- },
3741
- vp8: {
3742
- 2160: 5000000,
3743
- 1440: 2750000,
3744
- 1080: 2000000,
3745
- 720: 1250000,
3746
- 540: 600000,
3747
- 360: 350000,
3748
- default: 1250000,
3749
- },
3750
- vp9: {
3751
- 2160: 3000000,
3752
- 1440: 2000000,
3753
- 1080: 1500000,
3754
- 720: 1250000,
3755
- 540: 500000,
3756
- 360: 275000,
3757
- default: 1250000,
3758
- },
3759
- av1: {
3760
- 2160: 2000000,
3761
- 1440: 1550000,
3762
- 1080: 1000000,
3763
- 720: 600000,
3764
- 540: 350000,
3765
- 360: 200000,
3766
- default: 600000,
3767
- },
3768
- };
3769
- const getOptimalBitrate = (codec, frameHeight) => {
3770
- const codecLookup = bitrateLookupTable[codec];
3771
- if (!codecLookup)
3772
- throw new Error(`Unknown codec: ${codec}`);
3773
- let bitrate = codecLookup[frameHeight];
3774
- if (!bitrate) {
3775
- const keys = Object.keys(codecLookup).map(Number);
3776
- const nearest = keys.reduce((a, b) => Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a);
3777
- bitrate = codecLookup[nearest];
3778
- }
3779
- return bitrate ?? codecLookup.default;
3780
- };
3781
-
3782
- const DEFAULT_BITRATE = 1250000;
3783
- const defaultTargetResolution = {
3784
- bitrate: DEFAULT_BITRATE,
3785
- width: 1280,
3786
- height: 720,
3787
- };
3788
- const defaultBitratePerRid = {
3789
- q: 300000,
3790
- h: 750000,
3791
- f: DEFAULT_BITRATE,
3792
- };
3793
- /**
3794
- * In SVC, we need to send only one video encoding (layer).
3795
- * this layer will have the additional spatial and temporal layers
3796
- * defined via the scalabilityMode property.
3797
- *
3798
- * @param layers the layers to process.
3799
- */
3800
- const toSvcEncodings = (layers) => {
3801
- // we take the `f` layer, and we rename it to `q`.
3802
- return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
3803
- };
3804
- /**
3805
- * Converts the rid to a video quality.
3806
- */
3807
- const ridToVideoQuality = (rid) => {
3808
- return rid === 'q'
3809
- ? VideoQuality.LOW_UNSPECIFIED
3810
- : rid === 'h'
3811
- ? VideoQuality.MID
3812
- : VideoQuality.HIGH; // default to HIGH
3813
- };
3814
- /**
3815
- * Determines the most optimal video layers for simulcasting
3816
- * for the given track.
3817
- *
3818
- * @param videoTrack the video track to find optimal layers for.
3819
- * @param targetResolution the expected target resolution.
3820
- * @param codecInUse the codec in use.
3821
- * @param publishOptions the publish options for the track.
3822
- */
3823
- const findOptimalVideoLayers = (videoTrack, targetResolution = defaultTargetResolution, codecInUse, publishOptions) => {
3824
- const optimalVideoLayers = [];
3825
- const settings = videoTrack.getSettings();
3826
- const { width = 0, height = 0 } = settings;
3827
- const { scalabilityMode, bitrateDownscaleFactor = 2, maxSimulcastLayers = 3, } = publishOptions || {};
3828
- const maxBitrate = getComputedMaxBitrate(targetResolution, width, height, codecInUse, publishOptions);
3829
- let downscaleFactor = 1;
3830
- let bitrateFactor = 1;
3831
- const svcCodec = isSvcCodec(codecInUse);
3832
- const totalLayers = svcCodec ? 3 : Math.min(3, maxSimulcastLayers);
3833
- for (const rid of ['f', 'h', 'q'].slice(0, totalLayers)) {
3834
- const layer = {
3835
- active: true,
3836
- rid,
3837
- width: Math.round(width / downscaleFactor),
3838
- height: Math.round(height / downscaleFactor),
3839
- maxBitrate: Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
3840
- maxFramerate: 30,
3556
+ this.dispose = () => {
3557
+ this.subscriberCandidates.complete();
3558
+ this.publisherCandidates.complete();
3841
3559
  };
3842
- if (svcCodec) {
3843
- // for SVC codecs, we need to set the scalability mode, and the
3844
- // codec will handle the rest (layers, temporal layers, etc.)
3845
- layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
3846
- }
3847
- else {
3848
- // for non-SVC codecs, we need to downscale proportionally (simulcast)
3849
- layer.scaleResolutionDownBy = downscaleFactor;
3850
- }
3851
- downscaleFactor *= 2;
3852
- bitrateFactor *= bitrateDownscaleFactor;
3853
- // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
3854
- // when deciding which layer to disable when CPU or bandwidth is constrained.
3855
- // Encodings should be ordered in increasing spatial resolution order.
3856
- optimalVideoLayers.unshift(layer);
3857
3560
  }
3858
- // for simplicity, we start with all layers enabled, then this function
3859
- // will clear/reassign the layers that are not needed
3860
- return withSimulcastConstraints(settings, optimalVideoLayers);
3861
- };
3862
- /**
3863
- * Computes the maximum bitrate for a given resolution.
3864
- * If the current resolution is lower than the target resolution,
3865
- * we want to proportionally reduce the target bitrate.
3866
- * If the current resolution is higher than the target resolution,
3867
- * we want to use the target bitrate.
3868
- *
3869
- * @param targetResolution the target resolution.
3870
- * @param currentWidth the current width of the track.
3871
- * @param currentHeight the current height of the track.
3872
- * @param codecInUse the codec in use.
3873
- * @param publishOptions the publish options.
3874
- */
3875
- const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, codecInUse, publishOptions) => {
3876
- // if the current resolution is lower than the target resolution,
3877
- // we want to proportionally reduce the target bitrate
3878
- const { width: targetWidth, height: targetHeight, bitrate: targetBitrate, } = targetResolution;
3879
- const { preferredBitrate } = publishOptions || {};
3880
- const frameHeight = currentWidth > currentHeight ? currentHeight : currentWidth;
3881
- const bitrate = preferredBitrate ||
3882
- (codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
3883
- if (currentWidth < targetWidth || currentHeight < targetHeight) {
3884
- const currentPixels = currentWidth * currentHeight;
3885
- const targetPixels = targetWidth * targetHeight;
3886
- const reductionFactor = currentPixels / targetPixels;
3887
- return Math.round(bitrate * reductionFactor);
3888
- }
3889
- return bitrate;
3890
- };
3891
- /**
3892
- * Browsers have different simulcast constraints for different video resolutions.
3893
- *
3894
- * This function modifies the provided list of video layers according to the
3895
- * current implementation of simulcast constraints in the Chromium based browsers.
3896
- *
3897
- * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
3898
- */
3899
- const withSimulcastConstraints = (settings, optimalVideoLayers) => {
3900
- let layers;
3901
- const size = Math.max(settings.width || 0, settings.height || 0);
3902
- if (size <= 320) {
3903
- // provide only one layer 320x240 (q), the one with the highest quality
3904
- layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
3905
- }
3906
- else if (size <= 640) {
3907
- // provide two layers, 160x120 (q) and 640x480 (h)
3908
- layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
3909
- }
3910
- else {
3911
- // provide three layers for sizes > 640x480
3912
- layers = optimalVideoLayers;
3913
- }
3914
- const ridMapping = ['q', 'h', 'f'];
3915
- return layers.map((layer, index) => ({
3916
- ...layer,
3917
- rid: ridMapping[index], // reassign rid
3918
- }));
3919
- };
3920
- const findOptimalScreenSharingLayers = (videoTrack, publishOptions, defaultMaxBitrate = 3000000) => {
3921
- const { screenShareSettings: preferences } = publishOptions || {};
3922
- const settings = videoTrack.getSettings();
3923
- return [
3924
- {
3925
- active: true,
3926
- rid: 'q', // single track, start from 'q'
3927
- width: settings.width || 0,
3928
- height: settings.height || 0,
3929
- scaleResolutionDownBy: 1,
3930
- maxBitrate: preferences?.maxBitrate ?? defaultMaxBitrate,
3931
- maxFramerate: preferences?.maxFramerate ?? 30,
3932
- },
3933
- ];
3934
- };
3935
-
3936
- const ensureExhausted = (x, message) => {
3937
- getLogger(['helpers'])('warn', message, x);
3938
- };
3939
-
3940
- const trackTypeToParticipantStreamKey = (trackType) => {
3941
- switch (trackType) {
3942
- case TrackType.SCREEN_SHARE:
3943
- return 'screenShareStream';
3944
- case TrackType.SCREEN_SHARE_AUDIO:
3945
- return 'screenShareAudioStream';
3946
- case TrackType.VIDEO:
3947
- return 'videoStream';
3948
- case TrackType.AUDIO:
3949
- return 'audioStream';
3950
- case TrackType.UNSPECIFIED:
3951
- throw new Error('Track type is unspecified');
3952
- default:
3953
- ensureExhausted(trackType, 'Unknown track type');
3954
- }
3955
- };
3956
- const muteTypeToTrackType = (muteType) => {
3957
- switch (muteType) {
3958
- case 'audio':
3959
- return TrackType.AUDIO;
3960
- case 'video':
3961
- return TrackType.VIDEO;
3962
- case 'screenshare':
3963
- return TrackType.SCREEN_SHARE;
3964
- case 'screenshare_audio':
3965
- return TrackType.SCREEN_SHARE_AUDIO;
3966
- default:
3967
- ensureExhausted(muteType, 'Unknown mute type');
3561
+ }
3562
+ const toIceCandidate = (iceTrickle) => {
3563
+ try {
3564
+ return JSON.parse(iceTrickle.iceCandidate);
3968
3565
  }
3969
- };
3970
- const toTrackType = (trackType) => {
3971
- switch (trackType) {
3972
- case 'TRACK_TYPE_AUDIO':
3973
- return TrackType.AUDIO;
3974
- case 'TRACK_TYPE_VIDEO':
3975
- return TrackType.VIDEO;
3976
- case 'TRACK_TYPE_SCREEN_SHARE':
3977
- return TrackType.SCREEN_SHARE;
3978
- case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
3979
- return TrackType.SCREEN_SHARE_AUDIO;
3980
- default:
3981
- return undefined;
3566
+ catch (e) {
3567
+ const logger = getLogger(['sfu-client']);
3568
+ logger('error', `Failed to parse ICE Trickle`, e, iceTrickle);
3569
+ return undefined;
3982
3570
  }
3983
3571
  };
3984
3572
 
@@ -4122,6 +3710,24 @@ const setCurrentValue = (subject, update) => {
4122
3710
  subject.next(next);
4123
3711
  return next;
4124
3712
  };
3713
+ /**
3714
+ * Updates the value of the provided Subject and returns the previous value
3715
+ * and a function to roll back the update.
3716
+ * This is useful when you want to optimistically update a value
3717
+ * and roll back the update if an error occurs.
3718
+ *
3719
+ * @param subject the subject to update.
3720
+ * @param update the update to apply to the subject.
3721
+ */
3722
+ const updateValue = (subject, update) => {
3723
+ const lastValue = subject.getValue();
3724
+ const value = setCurrentValue(subject, update);
3725
+ return {
3726
+ lastValue,
3727
+ value,
3728
+ rollback: () => setCurrentValue(subject, lastValue),
3729
+ };
3730
+ };
4125
3731
  /**
4126
3732
  * Creates a subscription and returns a function to unsubscribe.
4127
3733
  *
@@ -4155,7 +3761,8 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4155
3761
  createSafeAsyncSubscription: createSafeAsyncSubscription,
4156
3762
  createSubscription: createSubscription,
4157
3763
  getCurrentValue: getCurrentValue,
4158
- setCurrentValue: setCurrentValue
3764
+ setCurrentValue: setCurrentValue,
3765
+ updateValue: updateValue
4159
3766
  });
4160
3767
 
4161
3768
  /**
@@ -4602,6 +4209,7 @@ class CallState {
4602
4209
  this.sessionSubject = new rxjs.BehaviorSubject(undefined);
4603
4210
  this.settingsSubject = new rxjs.BehaviorSubject(undefined);
4604
4211
  this.transcribingSubject = new rxjs.BehaviorSubject(false);
4212
+ this.captioningSubject = new rxjs.BehaviorSubject(false);
4605
4213
  this.endedBySubject = new rxjs.BehaviorSubject(undefined);
4606
4214
  this.thumbnailsSubject = new rxjs.BehaviorSubject(undefined);
4607
4215
  this.membersSubject = new rxjs.BehaviorSubject([]);
@@ -4612,6 +4220,7 @@ class CallState {
4612
4220
  this.anonymousParticipantCountSubject = new rxjs.BehaviorSubject(0);
4613
4221
  this.participantsSubject = new rxjs.BehaviorSubject([]);
4614
4222
  this.callStatsReportSubject = new rxjs.BehaviorSubject(undefined);
4223
+ this.closedCaptionsSubject = new rxjs.BehaviorSubject([]);
4615
4224
  // These are tracks that were delivered to the Subscriber's onTrack event
4616
4225
  // that we couldn't associate with a participant yet.
4617
4226
  // This happens when the participantJoined event hasn't been received yet.
@@ -4620,10 +4229,18 @@ class CallState {
4620
4229
  this.logger = getLogger(['CallState']);
4621
4230
  /**
4622
4231
  * A list of comparators that are used to sort the participants.
4623
- *
4624
- * @private
4625
4232
  */
4626
4233
  this.sortParticipantsBy = defaultSortPreset;
4234
+ this.closedCaptionsTasks = new Map();
4235
+ /**
4236
+ * Runs the cleanup tasks.
4237
+ */
4238
+ this.dispose = () => {
4239
+ for (const [ccKey, taskId] of this.closedCaptionsTasks.entries()) {
4240
+ clearTimeout(taskId);
4241
+ this.closedCaptionsTasks.delete(ccKey);
4242
+ }
4243
+ };
4627
4244
  /**
4628
4245
  * Sets the list of criteria that are used to sort the participants.
4629
4246
  * To disable sorting, you can pass `noopComparator()`.
@@ -4672,6 +4289,15 @@ class CallState {
4672
4289
  this.setStartedAt = (startedAt) => {
4673
4290
  return this.setCurrentValue(this.startedAtSubject, startedAt);
4674
4291
  };
4292
+ /**
4293
+ * Sets the closed captioning state of the current call.
4294
+ *
4295
+ * @internal
4296
+ * @param captioning the closed captioning state.
4297
+ */
4298
+ this.setCaptioning = (captioning) => {
4299
+ return updateValue(this.captioningSubject, captioning);
4300
+ };
4675
4301
  /**
4676
4302
  * Sets the number of anonymous participants in the current call.
4677
4303
  *
@@ -4770,7 +4396,6 @@ class CallState {
4770
4396
  }
4771
4397
  const thePatch = typeof patch === 'function' ? patch(participant) : patch;
4772
4398
  const updatedParticipant = {
4773
- // FIXME OL: this is not a deep merge, we might want to revisit this
4774
4399
  ...participant,
4775
4400
  ...thePatch,
4776
4401
  };
@@ -4832,7 +4457,6 @@ class CallState {
4832
4457
  *
4833
4458
  * @param trackType the kind of subscription to update.
4834
4459
  * @param changes the list of subscription changes to do.
4835
- * @param type the debounce type to use for the update.
4836
4460
  */
4837
4461
  this.updateParticipantTracks = (trackType, changes) => {
4838
4462
  return this.updateParticipants(Object.entries(changes).reduce((acc, [sessionId, change]) => {
@@ -4935,6 +4559,14 @@ class CallState {
4935
4559
  }
4936
4560
  return orphans;
4937
4561
  };
4562
+ /**
4563
+ * Updates the closed captions settings.
4564
+ *
4565
+ * @param config the new closed captions settings.
4566
+ */
4567
+ this.updateClosedCaptionSettings = (config) => {
4568
+ this.closedCaptionsSettings = { ...this.closedCaptionsSettings, ...config };
4569
+ };
4938
4570
  /**
4939
4571
  * Updates the call state with the data received from the server.
4940
4572
  *
@@ -4958,6 +4590,7 @@ class CallState {
4958
4590
  this.updateParticipantCountFromSession(s);
4959
4591
  this.setCurrentValue(this.settingsSubject, call.settings);
4960
4592
  this.setCurrentValue(this.transcribingSubject, call.transcribing);
4593
+ this.setCurrentValue(this.captioningSubject, call.captioning);
4961
4594
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
4962
4595
  };
4963
4596
  /**
@@ -5149,6 +4782,35 @@ class CallState {
5149
4782
  this.setCurrentValue(this.ownCapabilitiesSubject, event.own_capabilities);
5150
4783
  }
5151
4784
  };
4785
+ this.updateFromClosedCaptions = (event) => {
4786
+ this.setCurrentValue(this.closedCaptionsSubject, (queue) => {
4787
+ const { closed_caption } = event;
4788
+ const keyOf = (c) => `${c.speaker_id}/${c.start_time}`;
4789
+ const currentKey = keyOf(closed_caption);
4790
+ const duplicate = queue.some((caption) => keyOf(caption) === currentKey);
4791
+ if (duplicate)
4792
+ return queue;
4793
+ const nextQueue = [...queue, closed_caption];
4794
+ const { visibilityDurationMs = 2700, maxVisibleCaptions = 2 } = this.closedCaptionsSettings || {};
4795
+ // schedule the removal of the closed caption after the retention time
4796
+ if (visibilityDurationMs > 0) {
4797
+ const taskId = setTimeout(() => {
4798
+ this.setCurrentValue(this.closedCaptionsSubject, (captions) => captions.filter((caption) => caption !== closed_caption));
4799
+ this.closedCaptionsTasks.delete(currentKey);
4800
+ }, visibilityDurationMs);
4801
+ this.closedCaptionsTasks.set(currentKey, taskId);
4802
+ // cancel the cleanup tasks for the closed captions that are no longer in the queue
4803
+ for (let i = 0; i < nextQueue.length - maxVisibleCaptions; i++) {
4804
+ const key = keyOf(nextQueue[i]);
4805
+ const task = this.closedCaptionsTasks.get(key);
4806
+ clearTimeout(task);
4807
+ this.closedCaptionsTasks.delete(key);
4808
+ }
4809
+ }
4810
+ // trim the queue
4811
+ return nextQueue.slice(-maxVisibleCaptions);
4812
+ });
4813
+ };
5152
4814
  this.participants$ = this.participantsSubject.asObservable().pipe(
5153
4815
  // maintain stable-sort by mutating the participants stored
5154
4816
  // in the original subject
@@ -5175,6 +4837,7 @@ class CallState {
5175
4837
  this.settings$ = this.settingsSubject.asObservable();
5176
4838
  this.endedBy$ = this.endedBySubject.asObservable();
5177
4839
  this.thumbnails$ = this.thumbnailsSubject.asObservable();
4840
+ this.closedCaptions$ = this.closedCaptionsSubject.asObservable();
5178
4841
  /**
5179
4842
  * Performs shallow comparison of two arrays.
5180
4843
  * Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
@@ -5204,9 +4867,9 @@ class CallState {
5204
4867
  this.participantCount$ = duc(this.participantCountSubject);
5205
4868
  this.recording$ = duc(this.recordingSubject);
5206
4869
  this.transcribing$ = duc(this.transcribingSubject);
4870
+ this.captioning$ = duc(this.captioningSubject);
5207
4871
  this.eventHandlers = {
5208
4872
  // these events are not updating the call state:
5209
- 'call.closed_caption': undefined,
5210
4873
  'call.deleted': undefined,
5211
4874
  'call.permission_request': undefined,
5212
4875
  'call.recording_ready': undefined,
@@ -5227,6 +4890,16 @@ class CallState {
5227
4890
  // events that update call state:
5228
4891
  'call.accepted': (e) => this.updateFromCallResponse(e.call),
5229
4892
  'call.blocked_user': this.blockUser,
4893
+ 'call.closed_caption': this.updateFromClosedCaptions,
4894
+ 'call.closed_captions_failed': () => {
4895
+ this.setCurrentValue(this.captioningSubject, false);
4896
+ },
4897
+ 'call.closed_captions_started': () => {
4898
+ this.setCurrentValue(this.captioningSubject, true);
4899
+ },
4900
+ 'call.closed_captions_stopped': () => {
4901
+ this.setCurrentValue(this.captioningSubject, false);
4902
+ },
5230
4903
  'call.created': (e) => this.updateFromCallResponse(e.call),
5231
4904
  'call.ended': (e) => {
5232
4905
  this.updateFromCallResponse(e.call);
@@ -5284,6 +4957,12 @@ class CallState {
5284
4957
  get startedAt() {
5285
4958
  return this.getCurrentValue(this.startedAt$);
5286
4959
  }
4960
+ /**
4961
+ * Returns whether closed captions are enabled in the current call.
4962
+ */
4963
+ get captioning() {
4964
+ return this.getCurrentValue(this.captioning$);
4965
+ }
5287
4966
  /**
5288
4967
  * The server-side counted number of anonymous participants connected to the current call.
5289
4968
  * This number includes the anonymous participants as well.
@@ -5447,200 +5126,454 @@ class CallState {
5447
5126
  get thumbnails() {
5448
5127
  return this.getCurrentValue(this.thumbnails$);
5449
5128
  }
5129
+ /**
5130
+ * Returns the current queue of closed captions.
5131
+ */
5132
+ get closedCaptions() {
5133
+ return this.getCurrentValue(this.closedCaptions$);
5134
+ }
5450
5135
  }
5451
5136
 
5452
- const getRtpMap = (line) => {
5453
- // Example: a=rtpmap:110 opus/48000/2
5454
- const rtpRegex = /^a=rtpmap:(\d*) ([\w\-.]*)(?:\s*\/(\d*)(?:\s*\/(\S*))?)?/;
5455
- // 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.
5456
- const rtpMatch = rtpRegex.exec(line);
5457
- if (rtpMatch) {
5458
- return {
5459
- original: rtpMatch[0],
5460
- payload: rtpMatch[1],
5461
- codec: rtpMatch[2],
5137
+ /**
5138
+ * A base class for the `Publisher` and `Subscriber` classes.
5139
+ * @internal
5140
+ */
5141
+ class BasePeerConnection {
5142
+ /**
5143
+ * Constructs a new `BasePeerConnection` instance.
5144
+ */
5145
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onUnrecoverableError, logTag, }) {
5146
+ this.isIceRestarting = false;
5147
+ this.subscriptions = [];
5148
+ /**
5149
+ * Disposes the `RTCPeerConnection` instance.
5150
+ */
5151
+ this.dispose = () => {
5152
+ this.detachEventHandlers();
5153
+ this.pc.close();
5154
+ };
5155
+ /**
5156
+ * Handles events synchronously.
5157
+ * Consecutive events are queued and executed one after the other.
5158
+ */
5159
+ this.on = (event, fn) => {
5160
+ this.subscriptions.push(this.dispatcher.on(event, (e) => {
5161
+ withoutConcurrency(`pc.${event}`, async () => fn(e)).catch((err) => {
5162
+ this.logger('warn', `Error handling ${event}`, err);
5163
+ });
5164
+ }));
5165
+ };
5166
+ /**
5167
+ * Appends the trickled ICE candidates to the `RTCPeerConnection`.
5168
+ */
5169
+ this.addTrickledIceCandidates = () => {
5170
+ const { iceTrickleBuffer } = this.sfuClient;
5171
+ const observable = this.peerType === PeerType.SUBSCRIBER
5172
+ ? iceTrickleBuffer.subscriberCandidates
5173
+ : iceTrickleBuffer.publisherCandidates;
5174
+ this.unsubscribeIceTrickle?.();
5175
+ this.unsubscribeIceTrickle = createSafeAsyncSubscription(observable, async (candidate) => {
5176
+ return this.pc.addIceCandidate(candidate).catch((e) => {
5177
+ this.logger('warn', `ICE candidate error`, e, candidate);
5178
+ });
5179
+ });
5180
+ };
5181
+ /**
5182
+ * Sets the SFU client to use.
5183
+ *
5184
+ * @param sfuClient the SFU client to use.
5185
+ */
5186
+ this.setSfuClient = (sfuClient) => {
5187
+ this.sfuClient = sfuClient;
5188
+ };
5189
+ /**
5190
+ * Returns the result of the `RTCPeerConnection.getStats()` method
5191
+ * @param selector an optional `MediaStreamTrack` to get the stats for.
5192
+ */
5193
+ this.getStats = (selector) => {
5194
+ return this.pc.getStats(selector);
5195
+ };
5196
+ /**
5197
+ * Handles the ICECandidate event and
5198
+ * Initiates an ICE Trickle process with the SFU.
5199
+ */
5200
+ this.onIceCandidate = (e) => {
5201
+ const { candidate } = e;
5202
+ if (!candidate) {
5203
+ this.logger('debug', 'null ice candidate');
5204
+ return;
5205
+ }
5206
+ const iceCandidate = this.toJSON(candidate);
5207
+ this.sfuClient
5208
+ .iceTrickle({ peerType: this.peerType, iceCandidate })
5209
+ .catch((err) => this.logger('warn', `ICETrickle failed`, err));
5210
+ };
5211
+ /**
5212
+ * Converts the ICE candidate to a JSON string.
5213
+ */
5214
+ this.toJSON = (candidate) => {
5215
+ if (!candidate.usernameFragment) {
5216
+ // react-native-webrtc doesn't include usernameFragment in the candidate
5217
+ const segments = candidate.candidate.split(' ');
5218
+ const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
5219
+ const usernameFragment = segments[ufragIndex];
5220
+ return JSON.stringify({ ...candidate, usernameFragment });
5221
+ }
5222
+ return JSON.stringify(candidate.toJSON());
5223
+ };
5224
+ /**
5225
+ * Handles the ICE connection state change event.
5226
+ */
5227
+ this.onIceConnectionStateChange = () => {
5228
+ const state = this.pc.iceConnectionState;
5229
+ this.logger('debug', `ICE connection state changed`, state);
5230
+ if (this.state.callingState === exports.CallingState.RECONNECTING)
5231
+ return;
5232
+ // do nothing when ICE is restarting
5233
+ if (this.isIceRestarting)
5234
+ return;
5235
+ if (state === 'failed' || state === 'disconnected') {
5236
+ this.logger('debug', `Attempting to restart ICE`);
5237
+ this.restartIce().catch((e) => {
5238
+ this.logger('error', `ICE restart failed`, e);
5239
+ this.onUnrecoverableError?.();
5240
+ });
5241
+ }
5242
+ };
5243
+ /**
5244
+ * Handles the ICE candidate error event.
5245
+ */
5246
+ this.onIceCandidateError = (e) => {
5247
+ const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
5248
+ `${e.errorCode}: ${e.errorText}`;
5249
+ const iceState = this.pc.iceConnectionState;
5250
+ const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
5251
+ this.logger(logLevel, `ICE Candidate error`, errorMessage);
5252
+ };
5253
+ /**
5254
+ * Handles the ICE gathering state change event.
5255
+ */
5256
+ this.onIceGatherChange = () => {
5257
+ this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
5258
+ };
5259
+ /**
5260
+ * Handles the signaling state change event.
5261
+ */
5262
+ this.onSignalingChange = () => {
5263
+ this.logger('debug', `Signaling state changed`, this.pc.signalingState);
5462
5264
  };
5265
+ this.peerType = peerType;
5266
+ this.sfuClient = sfuClient;
5267
+ this.state = state;
5268
+ this.dispatcher = dispatcher;
5269
+ this.onUnrecoverableError = onUnrecoverableError;
5270
+ this.logger = getLogger([
5271
+ peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
5272
+ logTag,
5273
+ ]);
5274
+ this.pc = new RTCPeerConnection(connectionConfig);
5275
+ this.pc.addEventListener('icecandidate', this.onIceCandidate);
5276
+ this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5277
+ this.pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5278
+ this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
5279
+ this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
5463
5280
  }
5464
- };
5465
- const getFmtp = (line) => {
5466
- // Example: a=fmtp:111 minptime=10; useinbandfec=1
5467
- const fmtpRegex = /^a=fmtp:(\d*) (.*)/;
5468
- const fmtpMatch = fmtpRegex.exec(line);
5469
- // The first captured group is the payload type number, the second captured group is any additional parameters.
5470
- if (fmtpMatch) {
5471
- return {
5472
- original: fmtpMatch[0],
5473
- payload: fmtpMatch[1],
5474
- config: fmtpMatch[2],
5281
+ /**
5282
+ * Detaches the event handlers from the `RTCPeerConnection`.
5283
+ */
5284
+ detachEventHandlers() {
5285
+ this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5286
+ this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5287
+ this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
5288
+ this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5289
+ this.pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
5290
+ this.unsubscribeIceTrickle?.();
5291
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
5292
+ }
5293
+ }
5294
+
5295
+ class TransceiverCache {
5296
+ constructor() {
5297
+ this.cache = [];
5298
+ this.layers = [];
5299
+ /**
5300
+ * An array maintaining the order how transceivers were added to the peer connection.
5301
+ * This is needed because some browsers (Firefox) don't reliably report
5302
+ * trackId and `mid` parameters.
5303
+ */
5304
+ this.transceiverOrder = [];
5305
+ /**
5306
+ * Adds a transceiver to the cache.
5307
+ */
5308
+ this.add = (publishOption, transceiver) => {
5309
+ this.cache.push({ publishOption, transceiver });
5310
+ this.transceiverOrder.push(transceiver);
5311
+ };
5312
+ /**
5313
+ * Gets the transceiver for the given publish option.
5314
+ */
5315
+ this.get = (publishOption) => {
5316
+ return this.findTransceiver(publishOption)?.transceiver;
5317
+ };
5318
+ /**
5319
+ * Gets the last transceiver for the given track type and publish option id.
5320
+ */
5321
+ this.getWith = (trackType, id) => {
5322
+ return this.findTransceiver({ trackType, id })?.transceiver;
5323
+ };
5324
+ /**
5325
+ * Checks if the cache has the given publish option.
5326
+ */
5327
+ this.has = (publishOption) => {
5328
+ return !!this.get(publishOption);
5329
+ };
5330
+ /**
5331
+ * Finds the first transceiver that satisfies the given predicate.
5332
+ */
5333
+ this.find = (predicate) => {
5334
+ return this.cache.find(predicate);
5335
+ };
5336
+ /**
5337
+ * Provides all the items in the cache.
5338
+ */
5339
+ this.items = () => {
5340
+ return this.cache;
5341
+ };
5342
+ /**
5343
+ * Init index of the transceiver in the cache.
5344
+ */
5345
+ this.indexOf = (transceiver) => {
5346
+ return this.transceiverOrder.indexOf(transceiver);
5475
5347
  };
5348
+ /**
5349
+ * Gets cached video layers for the given track.
5350
+ */
5351
+ this.getLayers = (publishOption) => {
5352
+ const entry = this.layers.find((item) => item.publishOption.id === publishOption.id &&
5353
+ item.publishOption.trackType === publishOption.trackType);
5354
+ return entry?.layers;
5355
+ };
5356
+ /**
5357
+ * Sets the video layers for the given track.
5358
+ */
5359
+ this.setLayers = (publishOption, layers = []) => {
5360
+ const entry = this.findLayer(publishOption);
5361
+ if (entry) {
5362
+ entry.layers = layers;
5363
+ }
5364
+ else {
5365
+ this.layers.push({ publishOption, layers });
5366
+ }
5367
+ };
5368
+ this.findTransceiver = (publishOption) => {
5369
+ return this.cache.find((item) => item.publishOption.id === publishOption.id &&
5370
+ item.publishOption.trackType === publishOption.trackType);
5371
+ };
5372
+ this.findLayer = (publishOption) => {
5373
+ return this.layers.find((item) => item.publishOption.id === publishOption.id &&
5374
+ item.publishOption.trackType === publishOption.trackType);
5375
+ };
5376
+ }
5377
+ }
5378
+
5379
+ const ensureExhausted = (x, message) => {
5380
+ getLogger(['helpers'])('warn', message, x);
5381
+ };
5382
+
5383
+ const trackTypeToParticipantStreamKey = (trackType) => {
5384
+ switch (trackType) {
5385
+ case TrackType.SCREEN_SHARE:
5386
+ return 'screenShareStream';
5387
+ case TrackType.SCREEN_SHARE_AUDIO:
5388
+ return 'screenShareAudioStream';
5389
+ case TrackType.VIDEO:
5390
+ return 'videoStream';
5391
+ case TrackType.AUDIO:
5392
+ return 'audioStream';
5393
+ case TrackType.UNSPECIFIED:
5394
+ throw new Error('Track type is unspecified');
5395
+ default:
5396
+ ensureExhausted(trackType, 'Unknown track type');
5476
5397
  }
5477
5398
  };
5399
+ const muteTypeToTrackType = (muteType) => {
5400
+ switch (muteType) {
5401
+ case 'audio':
5402
+ return TrackType.AUDIO;
5403
+ case 'video':
5404
+ return TrackType.VIDEO;
5405
+ case 'screenshare':
5406
+ return TrackType.SCREEN_SHARE;
5407
+ case 'screenshare_audio':
5408
+ return TrackType.SCREEN_SHARE_AUDIO;
5409
+ default:
5410
+ ensureExhausted(muteType, 'Unknown mute type');
5411
+ }
5412
+ };
5413
+ const toTrackType = (trackType) => {
5414
+ switch (trackType) {
5415
+ case 'TRACK_TYPE_AUDIO':
5416
+ return TrackType.AUDIO;
5417
+ case 'TRACK_TYPE_VIDEO':
5418
+ return TrackType.VIDEO;
5419
+ case 'TRACK_TYPE_SCREEN_SHARE':
5420
+ return TrackType.SCREEN_SHARE;
5421
+ case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
5422
+ return TrackType.SCREEN_SHARE_AUDIO;
5423
+ default:
5424
+ return undefined;
5425
+ }
5426
+ };
5427
+ const isAudioTrackType = (trackType) => trackType === TrackType.AUDIO || trackType === TrackType.SCREEN_SHARE_AUDIO;
5428
+
5429
+ const defaultBitratePerRid = {
5430
+ q: 300000,
5431
+ h: 750000,
5432
+ f: 1250000,
5433
+ };
5478
5434
  /**
5479
- * gets the media section for the specified media type.
5480
- * The media section contains the media type, port, codec, and payload type.
5481
- * Example: m=video 9 UDP/TLS/RTP/SAVPF 100 101 96 97 35 36 102 125 127
5435
+ * In SVC, we need to send only one video encoding (layer).
5436
+ * this layer will have the additional spatial and temporal layers
5437
+ * defined via the scalabilityMode property.
5438
+ *
5439
+ * @param layers the layers to process.
5482
5440
  */
5483
- const getMedia = (line, mediaType) => {
5484
- const regex = new RegExp(`(m=${mediaType} \\d+ [\\w/]+) ([\\d\\s]+)`);
5485
- const match = regex.exec(line);
5486
- if (match) {
5487
- return {
5488
- original: match[0],
5489
- mediaWithPorts: match[1],
5490
- codecOrder: match[2],
5491
- };
5492
- }
5441
+ const toSvcEncodings = (layers) => {
5442
+ if (!layers)
5443
+ return;
5444
+ // we take the highest quality layer, and we assign it to `q` encoder.
5445
+ const withRid = (rid) => (l) => l.rid === rid;
5446
+ const highestLayer = layers.find(withRid('f')) ||
5447
+ layers.find(withRid('h')) ||
5448
+ layers.find(withRid('q'));
5449
+ return [{ ...highestLayer, rid: 'q' }];
5493
5450
  };
5494
- const getMediaSection = (sdp, mediaType) => {
5495
- let media;
5496
- const rtpMap = [];
5497
- const fmtp = [];
5498
- let isTheRequiredMediaSection = false;
5499
- sdp.split(/(\r\n|\r|\n)/).forEach((line) => {
5500
- const isValidLine = /^([a-z])=(.*)/.test(line);
5501
- if (!isValidLine)
5502
- return;
5503
- /*
5504
- NOTE: according to https://www.rfc-editor.org/rfc/rfc8866.pdf
5505
- 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
5506
- */
5507
- const type = line[0];
5508
- if (type === 'm') {
5509
- const _media = getMedia(line, mediaType);
5510
- isTheRequiredMediaSection = !!_media;
5511
- if (_media) {
5512
- media = _media;
5513
- }
5451
+ /**
5452
+ * Converts the rid to a video quality.
5453
+ */
5454
+ const ridToVideoQuality = (rid) => {
5455
+ return rid === 'q'
5456
+ ? VideoQuality.LOW_UNSPECIFIED
5457
+ : rid === 'h'
5458
+ ? VideoQuality.MID
5459
+ : VideoQuality.HIGH; // default to HIGH
5460
+ };
5461
+ /**
5462
+ * Converts the given video layers to SFU video layers.
5463
+ */
5464
+ const toVideoLayers = (layers = []) => {
5465
+ return layers.map((layer) => ({
5466
+ rid: layer.rid || '',
5467
+ bitrate: layer.maxBitrate || 0,
5468
+ fps: layer.maxFramerate || 0,
5469
+ quality: ridToVideoQuality(layer.rid || ''),
5470
+ videoDimension: { width: layer.width, height: layer.height },
5471
+ }));
5472
+ };
5473
+ /**
5474
+ * Converts the spatial and temporal layers to a scalability mode.
5475
+ */
5476
+ const toScalabilityMode = (spatialLayers, temporalLayers) => `L${spatialLayers}T${temporalLayers}${spatialLayers > 1 ? '_KEY' : ''}`;
5477
+ /**
5478
+ * Determines the most optimal video layers for the given track.
5479
+ *
5480
+ * @param videoTrack the video track to find optimal layers for.
5481
+ * @param publishOption the publish options for the track.
5482
+ */
5483
+ const computeVideoLayers = (videoTrack, publishOption) => {
5484
+ if (isAudioTrackType(publishOption.trackType))
5485
+ return;
5486
+ const optimalVideoLayers = [];
5487
+ const settings = videoTrack.getSettings();
5488
+ const { width = 0, height = 0 } = settings;
5489
+ const { bitrate, codec, fps, maxSpatialLayers = 3, maxTemporalLayers = 3, videoDimension = { width: 1280, height: 720 }, } = publishOption;
5490
+ const maxBitrate = getComputedMaxBitrate(videoDimension, width, height, bitrate);
5491
+ let downscaleFactor = 1;
5492
+ let bitrateFactor = 1;
5493
+ const svcCodec = isSvcCodec(codec?.name);
5494
+ for (const rid of ['f', 'h', 'q'].slice(0, maxSpatialLayers)) {
5495
+ const layer = {
5496
+ active: true,
5497
+ rid,
5498
+ width: Math.round(width / downscaleFactor),
5499
+ height: Math.round(height / downscaleFactor),
5500
+ maxBitrate: maxBitrate / bitrateFactor || defaultBitratePerRid[rid],
5501
+ maxFramerate: fps,
5502
+ };
5503
+ if (svcCodec) {
5504
+ // for SVC codecs, we need to set the scalability mode, and the
5505
+ // codec will handle the rest (layers, temporal layers, etc.)
5506
+ layer.scalabilityMode = toScalabilityMode(maxSpatialLayers, maxTemporalLayers);
5514
5507
  }
5515
- else if (isTheRequiredMediaSection && type === 'a') {
5516
- const rtpMapLine = getRtpMap(line);
5517
- const fmtpLine = getFmtp(line);
5518
- if (rtpMapLine) {
5519
- rtpMap.push(rtpMapLine);
5520
- }
5521
- else if (fmtpLine) {
5522
- fmtp.push(fmtpLine);
5523
- }
5508
+ else {
5509
+ // for non-SVC codecs, we need to downscale proportionally (simulcast)
5510
+ layer.scaleResolutionDownBy = downscaleFactor;
5524
5511
  }
5525
- });
5526
- if (media) {
5527
- return {
5528
- media,
5529
- rtpMap,
5530
- fmtp,
5531
- };
5512
+ downscaleFactor *= 2;
5513
+ bitrateFactor *= 2;
5514
+ // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
5515
+ // when deciding which layer to disable when CPU or bandwidth is constrained.
5516
+ // Encodings should be ordered in increasing spatial resolution order.
5517
+ optimalVideoLayers.unshift(layer);
5532
5518
  }
5519
+ // for simplicity, we start with all layers enabled, then this function
5520
+ // will clear/reassign the layers that are not needed
5521
+ return withSimulcastConstraints(settings, optimalVideoLayers);
5533
5522
  };
5534
5523
  /**
5535
- * Gets the fmtp line corresponding to opus
5524
+ * Computes the maximum bitrate for a given resolution.
5525
+ * If the current resolution is lower than the target resolution,
5526
+ * we want to proportionally reduce the target bitrate.
5527
+ * If the current resolution is higher than the target resolution,
5528
+ * we want to use the target bitrate.
5529
+ *
5530
+ * @param targetResolution the target resolution.
5531
+ * @param currentWidth the current width of the track.
5532
+ * @param currentHeight the current height of the track.
5533
+ * @param bitrate the target bitrate.
5536
5534
  */
5537
- const getOpusFmtp = (sdp) => {
5538
- const section = getMediaSection(sdp, 'audio');
5539
- const rtpMap = section?.rtpMap.find((r) => r.codec.toLowerCase() === 'opus');
5540
- const codecId = rtpMap?.payload;
5541
- if (codecId) {
5542
- return section?.fmtp.find((f) => f.payload === codecId);
5535
+ const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, bitrate) => {
5536
+ // if the current resolution is lower than the target resolution,
5537
+ // we want to proportionally reduce the target bitrate
5538
+ const { width: targetWidth, height: targetHeight } = targetResolution;
5539
+ if (currentWidth < targetWidth || currentHeight < targetHeight) {
5540
+ const currentPixels = currentWidth * currentHeight;
5541
+ const targetPixels = targetWidth * targetHeight;
5542
+ const reductionFactor = currentPixels / targetPixels;
5543
+ return Math.round(bitrate * reductionFactor);
5543
5544
  }
5545
+ return bitrate;
5544
5546
  };
5545
5547
  /**
5546
- * Returns an SDP with DTX enabled or disabled.
5547
- */
5548
- const toggleDtx = (sdp, enable) => {
5549
- const opusFmtp = getOpusFmtp(sdp);
5550
- if (!opusFmtp)
5551
- return sdp;
5552
- const matchDtx = /usedtx=(\d)/.exec(opusFmtp.config);
5553
- const requiredDtxConfig = `usedtx=${enable ? '1' : '0'}`;
5554
- const newFmtp = matchDtx
5555
- ? opusFmtp.original.replace(/usedtx=(\d)/, requiredDtxConfig)
5556
- : `${opusFmtp.original};${requiredDtxConfig}`;
5557
- return sdp.replace(opusFmtp.original, newFmtp);
5558
- };
5559
- /**
5560
- * Returns and SDP with all the codecs except the given codec removed.
5561
- */
5562
- const preserveCodec = (sdp, mid, codec) => {
5563
- const [kind, codecName] = codec.mimeType.toLowerCase().split('/');
5564
- const toSet = (fmtpLine) => new Set(fmtpLine.split(';').map((f) => f.trim().toLowerCase()));
5565
- const equal = (a, b) => {
5566
- if (a.size !== b.size)
5567
- return false;
5568
- for (const item of a)
5569
- if (!b.has(item))
5570
- return false;
5571
- return true;
5572
- };
5573
- const codecFmtp = toSet(codec.sdpFmtpLine || '');
5574
- const parsedSdp = SDP__namespace.parse(sdp);
5575
- for (const media of parsedSdp.media) {
5576
- if (media.type !== kind || String(media.mid) !== mid)
5577
- continue;
5578
- // find the payload id of the desired codec
5579
- const payloads = new Set();
5580
- for (const rtp of media.rtp) {
5581
- if (rtp.codec.toLowerCase() !== codecName)
5582
- continue;
5583
- const match =
5584
- // vp8 doesn't have any fmtp, we preserve it without any additional checks
5585
- codecName === 'vp8'
5586
- ? true
5587
- : media.fmtp.some((f) => f.payload === rtp.payload && equal(toSet(f.config), codecFmtp));
5588
- if (match) {
5589
- payloads.add(rtp.payload);
5590
- }
5591
- }
5592
- // find the corresponding rtx codec by matching apt=<preserved-codec-payload>
5593
- for (const fmtp of media.fmtp) {
5594
- const match = fmtp.config.match(/(apt)=(\d+)/);
5595
- if (!match)
5596
- continue;
5597
- const [, , preservedCodecPayload] = match;
5598
- if (payloads.has(Number(preservedCodecPayload))) {
5599
- payloads.add(fmtp.payload);
5600
- }
5601
- }
5602
- media.rtp = media.rtp.filter((r) => payloads.has(r.payload));
5603
- media.fmtp = media.fmtp.filter((f) => payloads.has(f.payload));
5604
- media.rtcpFb = media.rtcpFb?.filter((f) => payloads.has(f.payload));
5605
- media.payloads = Array.from(payloads).join(' ');
5606
- }
5607
- return SDP__namespace.write(parsedSdp);
5608
- };
5609
- /**
5610
- * Enables high-quality audio through SDP munging for the given trackMid.
5548
+ * Browsers have different simulcast constraints for different video resolutions.
5611
5549
  *
5612
- * @param sdp the SDP to munge.
5613
- * @param trackMid the trackMid.
5614
- * @param maxBitrate the max bitrate to set.
5615
- */
5616
- const enableHighQualityAudio = (sdp, trackMid, maxBitrate = 510000) => {
5617
- maxBitrate = Math.max(Math.min(maxBitrate, 510000), 96000);
5618
- const parsedSdp = SDP__namespace.parse(sdp);
5619
- const audioMedia = parsedSdp.media.find((m) => m.type === 'audio' && String(m.mid) === trackMid);
5620
- if (!audioMedia)
5621
- return sdp;
5622
- const opusRtp = audioMedia.rtp.find((r) => r.codec === 'opus');
5623
- if (!opusRtp)
5624
- return sdp;
5625
- const opusFmtp = audioMedia.fmtp.find((f) => f.payload === opusRtp.payload);
5626
- if (!opusFmtp)
5627
- return sdp;
5628
- // enable stereo, if not already enabled
5629
- if (opusFmtp.config.match(/stereo=(\d)/)) {
5630
- opusFmtp.config = opusFmtp.config.replace(/stereo=(\d)/, 'stereo=1');
5631
- }
5632
- else {
5633
- opusFmtp.config = `${opusFmtp.config};stereo=1`;
5550
+ * This function modifies the provided list of video layers according to the
5551
+ * current implementation of simulcast constraints in the Chromium based browsers.
5552
+ *
5553
+ * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
5554
+ */
5555
+ const withSimulcastConstraints = (settings, optimalVideoLayers) => {
5556
+ let layers;
5557
+ const size = Math.max(settings.width || 0, settings.height || 0);
5558
+ if (size <= 320) {
5559
+ // provide only one layer 320x240 (q), the one with the highest quality
5560
+ layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
5634
5561
  }
5635
- // set maxaveragebitrate, to the given value
5636
- if (opusFmtp.config.match(/maxaveragebitrate=(\d*)/)) {
5637
- opusFmtp.config = opusFmtp.config.replace(/maxaveragebitrate=(\d*)/, `maxaveragebitrate=${maxBitrate}`);
5562
+ else if (size <= 640) {
5563
+ // provide two layers, 160x120 (q) and 640x480 (h)
5564
+ layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
5638
5565
  }
5639
5566
  else {
5640
- opusFmtp.config = `${opusFmtp.config};maxaveragebitrate=${maxBitrate}`;
5567
+ // provide three layers for sizes > 640x480
5568
+ layers = optimalVideoLayers;
5641
5569
  }
5642
- return SDP__namespace.write(parsedSdp);
5570
+ const ridMapping = ['q', 'h', 'f'];
5571
+ return layers.map((layer, index) => ({
5572
+ ...layer,
5573
+ rid: ridMapping[index], // reassign rid
5574
+ }));
5643
5575
  };
5576
+
5644
5577
  /**
5645
5578
  * Extracts the mid from the transceiver or the SDP.
5646
5579
  *
@@ -5652,9 +5585,9 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5652
5585
  if (transceiver.mid)
5653
5586
  return transceiver.mid;
5654
5587
  if (!sdp)
5655
- return '';
5588
+ return String(transceiverInitIndex);
5656
5589
  const track = transceiver.sender.track;
5657
- const parsedSdp = SDP__namespace.parse(sdp);
5590
+ const parsedSdp = sdpTransform.parse(sdp);
5658
5591
  const media = parsedSdp.media.find((m) => {
5659
5592
  return (m.type === track.kind &&
5660
5593
  // if `msid` is not present, we assume that the track is the first one
@@ -5662,7 +5595,7 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5662
5595
  });
5663
5596
  if (typeof media?.mid !== 'undefined')
5664
5597
  return String(media.mid);
5665
- if (transceiverInitIndex === -1)
5598
+ if (transceiverInitIndex < 0)
5666
5599
  return '';
5667
5600
  return String(transceiverInitIndex);
5668
5601
  };
@@ -5672,164 +5605,87 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5672
5605
  *
5673
5606
  * @internal
5674
5607
  */
5675
- class Publisher {
5608
+ class Publisher extends BasePeerConnection {
5676
5609
  /**
5677
5610
  * Constructs a new `Publisher` instance.
5678
5611
  */
5679
- constructor({ connectionConfig, sfuClient, dispatcher, state, isDtxEnabled, isRedEnabled, onUnrecoverableError, logTag, }) {
5680
- this.transceiverCache = new Map();
5681
- this.trackLayersCache = new Map();
5682
- this.publishOptsForTrack = new Map();
5683
- /**
5684
- * An array maintaining the order how transceivers were added to the peer connection.
5685
- * This is needed because some browsers (Firefox) don't reliably report
5686
- * trackId and `mid` parameters.
5687
- *
5688
- * @internal
5689
- */
5690
- this.transceiverInitOrder = [];
5691
- this.isIceRestarting = false;
5692
- this.createPeerConnection = (connectionConfig) => {
5693
- const pc = new RTCPeerConnection(connectionConfig);
5694
- pc.addEventListener('icecandidate', this.onIceCandidate);
5695
- pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5696
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5697
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5698
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5699
- pc.addEventListener('signalingstatechange', this.onSignalingStateChange);
5700
- return pc;
5701
- };
5702
- /**
5703
- * Closes the publisher PeerConnection and cleans up the resources.
5704
- */
5705
- this.close = ({ stopTracks }) => {
5706
- if (stopTracks) {
5707
- this.stopPublishing();
5708
- this.transceiverCache.clear();
5709
- this.trackLayersCache.clear();
5710
- }
5711
- this.detachEventHandlers();
5712
- this.pc.close();
5713
- };
5714
- /**
5715
- * Detaches the event handlers from the `RTCPeerConnection`.
5716
- * This is useful when we want to replace the `RTCPeerConnection`
5717
- * instance with a new one (in case of migration).
5718
- */
5719
- this.detachEventHandlers = () => {
5720
- this.unsubscribeOnIceRestart();
5721
- this.unsubscribeChangePublishQuality();
5722
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5723
- this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5724
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5725
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5726
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5727
- this.pc.removeEventListener('signalingstatechange', this.onSignalingStateChange);
5728
- };
5612
+ constructor({ publishOptions, ...baseOptions }) {
5613
+ super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
5614
+ this.transceiverCache = new TransceiverCache();
5729
5615
  /**
5730
5616
  * Starts publishing the given track of the given media stream.
5731
5617
  *
5732
5618
  * Consecutive calls to this method will replace the stream.
5733
5619
  * The previous stream will be stopped.
5734
5620
  *
5735
- * @param mediaStream the media stream to publish.
5736
5621
  * @param track the track to publish.
5737
5622
  * @param trackType the track type to publish.
5738
- * @param opts the optional publish options to use.
5739
5623
  */
5740
- this.publishStream = async (mediaStream, track, trackType, opts = {}) => {
5741
- if (track.readyState === 'ended') {
5742
- throw new Error(`Can't publish a track that has ended already.`);
5743
- }
5744
- // enable the track if it is disabled
5745
- if (!track.enabled)
5746
- track.enabled = true;
5747
- const transceiver = this.transceiverCache.get(trackType);
5748
- if (!transceiver || !transceiver.sender.track) {
5749
- // listen for 'ended' event on the track as it might be ended abruptly
5750
- // by an external factors such as permission revokes, a disconnected device, etc.
5751
- // keep in mind that `track.stop()` doesn't trigger this event.
5752
- const handleTrackEnded = () => {
5753
- this.logger('info', `Track ${TrackType[trackType]} has ended abruptly`);
5754
- track.removeEventListener('ended', handleTrackEnded);
5755
- this.notifyTrackMuteStateChanged(mediaStream, trackType, true).catch((err) => this.logger('warn', `Couldn't notify track mute state`, err));
5756
- };
5757
- track.addEventListener('ended', handleTrackEnded);
5758
- this.addTransceiver(trackType, track, opts, mediaStream);
5624
+ this.publish = async (track, trackType) => {
5625
+ if (!this.publishOptions.some((o) => o.trackType === trackType)) {
5626
+ throw new Error(`No publish options found for ${TrackType[trackType]}`);
5759
5627
  }
5760
- else {
5761
- await this.updateTransceiver(transceiver, track);
5628
+ for (const publishOption of this.publishOptions) {
5629
+ if (publishOption.trackType !== trackType)
5630
+ continue;
5631
+ // create a clone of the track as otherwise the same trackId will
5632
+ // appear in the SDP in multiple transceivers
5633
+ const trackToPublish = track.clone();
5634
+ const transceiver = this.transceiverCache.get(publishOption);
5635
+ if (!transceiver) {
5636
+ this.addTransceiver(trackToPublish, publishOption);
5637
+ }
5638
+ else {
5639
+ await transceiver.sender.replaceTrack(trackToPublish);
5640
+ }
5762
5641
  }
5763
- await this.notifyTrackMuteStateChanged(mediaStream, trackType, false);
5764
5642
  };
5765
5643
  /**
5766
- * Adds a new transceiver to the peer connection.
5767
- * This needs to be called when a new track kind is added to the peer connection.
5768
- * In other cases, use `updateTransceiver` method.
5644
+ * Adds a new transceiver carrying the given track to the peer connection.
5769
5645
  */
5770
- this.addTransceiver = (trackType, track, opts, mediaStream) => {
5771
- const { forceCodec, preferredCodec } = opts;
5772
- const codecInUse = forceCodec || getOptimalVideoCodec(preferredCodec);
5773
- const videoEncodings = this.computeLayers(trackType, track, opts);
5646
+ this.addTransceiver = (track, publishOption) => {
5647
+ const videoEncodings = computeVideoLayers(track, publishOption);
5648
+ const sendEncodings = isSvcCodec(publishOption.codec?.name)
5649
+ ? toSvcEncodings(videoEncodings)
5650
+ : videoEncodings;
5774
5651
  const transceiver = this.pc.addTransceiver(track, {
5775
5652
  direction: 'sendonly',
5776
- streams: trackType === TrackType.VIDEO || trackType === TrackType.SCREEN_SHARE
5777
- ? [mediaStream]
5778
- : undefined,
5779
- sendEncodings: isSvcCodec(codecInUse)
5780
- ? toSvcEncodings(videoEncodings)
5781
- : videoEncodings,
5653
+ sendEncodings,
5782
5654
  });
5655
+ const trackType = publishOption.trackType;
5783
5656
  this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
5784
- this.transceiverInitOrder.push(trackType);
5785
- this.transceiverCache.set(trackType, transceiver);
5786
- this.publishOptsForTrack.set(trackType, opts);
5787
- // handle codec preferences
5788
- if (!('setCodecPreferences' in transceiver))
5789
- return;
5790
- const codecPreferences = this.getCodecPreferences(trackType, trackType === TrackType.VIDEO ? codecInUse : undefined, 'receiver');
5791
- if (!codecPreferences)
5792
- return;
5793
- try {
5794
- this.logger('info', `Setting ${TrackType[trackType]} codec preferences`, codecPreferences);
5795
- transceiver.setCodecPreferences(codecPreferences);
5796
- }
5797
- catch (err) {
5798
- this.logger('warn', `Couldn't set codec preferences`, err);
5799
- }
5800
- };
5801
- /**
5802
- * Updates the given transceiver with the new track.
5803
- * Stops the previous track and replaces it with the new one.
5804
- */
5805
- this.updateTransceiver = async (transceiver, track) => {
5806
- const previousTrack = transceiver.sender.track;
5807
- // don't stop the track if we are re-publishing the same track
5808
- if (previousTrack && previousTrack !== track) {
5809
- previousTrack.stop();
5810
- }
5811
- await transceiver.sender.replaceTrack(track);
5657
+ this.transceiverCache.add(publishOption, transceiver);
5812
5658
  };
5813
5659
  /**
5814
- * Stops publishing the given track type to the SFU, if it is currently being published.
5815
- * Underlying track will be stopped and removed from the publisher.
5816
- * @param trackType the track type to unpublish.
5817
- * @param stopTrack specifies whether track should be stopped or just disabled
5660
+ * Synchronizes the current Publisher state with the provided publish options.
5818
5661
  */
5819
- this.unpublishStream = async (trackType, stopTrack) => {
5820
- const transceiver = this.transceiverCache.get(trackType);
5821
- if (transceiver &&
5822
- transceiver.sender.track &&
5823
- (stopTrack
5824
- ? transceiver.sender.track.readyState === 'live'
5825
- : transceiver.sender.track.enabled)) {
5826
- stopTrack
5827
- ? transceiver.sender.track.stop()
5828
- : (transceiver.sender.track.enabled = false);
5829
- // We don't need to notify SFU if unpublishing in response to remote soft mute
5830
- if (this.state.localParticipant?.publishedTracks.includes(trackType)) {
5831
- await this.notifyTrackMuteStateChanged(undefined, trackType, true);
5832
- }
5662
+ this.syncPublishOptions = async () => {
5663
+ // enable publishing with new options -> [av1, vp9]
5664
+ for (const publishOption of this.publishOptions) {
5665
+ const { trackType } = publishOption;
5666
+ if (!this.isPublishing(trackType))
5667
+ continue;
5668
+ if (this.transceiverCache.has(publishOption))
5669
+ continue;
5670
+ const item = this.transceiverCache.find((i) => !!i.transceiver.sender.track &&
5671
+ i.publishOption.trackType === trackType);
5672
+ if (!item || !item.transceiver)
5673
+ continue;
5674
+ // take the track from the existing transceiver for the same track type,
5675
+ // clone it and publish it with the new publish options
5676
+ const track = item.transceiver.sender.track.clone();
5677
+ this.addTransceiver(track, publishOption);
5678
+ }
5679
+ // stop publishing with options not required anymore -> [vp9]
5680
+ for (const item of this.transceiverCache.items()) {
5681
+ const { publishOption, transceiver } = item;
5682
+ const hasPublishOption = this.publishOptions.some((option) => option.id === publishOption.id &&
5683
+ option.trackType === publishOption.trackType);
5684
+ if (hasPublishOption)
5685
+ continue;
5686
+ // it is safe to stop the track here, it is a clone
5687
+ transceiver.sender.track?.stop();
5688
+ await transceiver.sender.replaceTrack(null);
5833
5689
  }
5834
5690
  };
5835
5691
  /**
@@ -5838,57 +5694,52 @@ class Publisher {
5838
5694
  * @param trackType the track type to check.
5839
5695
  */
5840
5696
  this.isPublishing = (trackType) => {
5841
- const transceiver = this.transceiverCache.get(trackType);
5842
- if (!transceiver || !transceiver.sender)
5843
- return false;
5844
- const track = transceiver.sender.track;
5845
- return !!track && track.readyState === 'live' && track.enabled;
5846
- };
5847
- this.notifyTrackMuteStateChanged = async (mediaStream, trackType, isMuted) => {
5848
- await this.sfuClient.updateMuteState(trackType, isMuted);
5849
- const audioOrVideoOrScreenShareStream = trackTypeToParticipantStreamKey(trackType);
5850
- if (!audioOrVideoOrScreenShareStream)
5851
- return;
5852
- if (isMuted) {
5853
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => ({
5854
- publishedTracks: p.publishedTracks.filter((t) => t !== trackType),
5855
- [audioOrVideoOrScreenShareStream]: undefined,
5856
- }));
5857
- }
5858
- else {
5859
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => {
5860
- return {
5861
- publishedTracks: p.publishedTracks.includes(trackType)
5862
- ? p.publishedTracks
5863
- : [...p.publishedTracks, trackType],
5864
- [audioOrVideoOrScreenShareStream]: mediaStream,
5865
- };
5866
- });
5697
+ for (const item of this.transceiverCache.items()) {
5698
+ if (item.publishOption.trackType !== trackType)
5699
+ continue;
5700
+ const track = item.transceiver.sender.track;
5701
+ if (!track)
5702
+ continue;
5703
+ if (track.readyState === 'live' && track.enabled)
5704
+ return true;
5867
5705
  }
5706
+ return false;
5868
5707
  };
5869
5708
  /**
5870
- * Stops publishing all tracks and stop all tracks.
5709
+ * Maps the given track ID to the corresponding track type.
5871
5710
  */
5872
- this.stopPublishing = () => {
5873
- this.logger('debug', 'Stopping publishing all tracks');
5874
- this.pc.getSenders().forEach((s) => {
5875
- s.track?.stop();
5876
- if (this.pc.signalingState !== 'closed') {
5877
- this.pc.removeTrack(s);
5711
+ this.getTrackType = (trackId) => {
5712
+ for (const transceiverId of this.transceiverCache.items()) {
5713
+ const { publishOption, transceiver } = transceiverId;
5714
+ if (transceiver.sender.track?.id === trackId) {
5715
+ return publishOption.trackType;
5878
5716
  }
5879
- });
5717
+ }
5718
+ return undefined;
5880
5719
  };
5881
- this.changePublishQuality = async (enabledLayers) => {
5882
- this.logger('info', 'Update publish quality, requested layers by SFU:', enabledLayers);
5883
- const videoSender = this.transceiverCache.get(TrackType.VIDEO)?.sender;
5884
- if (!videoSender) {
5885
- this.logger('warn', 'Update publish quality, no video sender found.');
5886
- return;
5720
+ /**
5721
+ * Stops the cloned track that is being published to the SFU.
5722
+ */
5723
+ this.stopTracks = (...trackTypes) => {
5724
+ for (const item of this.transceiverCache.items()) {
5725
+ const { publishOption, transceiver } = item;
5726
+ if (!trackTypes.includes(publishOption.trackType))
5727
+ continue;
5728
+ transceiver.sender.track?.stop();
5729
+ }
5730
+ };
5731
+ this.changePublishQuality = async (videoSender) => {
5732
+ const { trackType, layers, publishOptionId } = videoSender;
5733
+ const enabledLayers = layers.filter((l) => l.active);
5734
+ const tag = 'Update publish quality:';
5735
+ this.logger('info', `${tag} requested layers by SFU:`, enabledLayers);
5736
+ const sender = this.transceiverCache.getWith(trackType, publishOptionId)?.sender;
5737
+ if (!sender) {
5738
+ return this.logger('warn', `${tag} no video sender found.`);
5887
5739
  }
5888
- const params = videoSender.getParameters();
5740
+ const params = sender.getParameters();
5889
5741
  if (params.encodings.length === 0) {
5890
- this.logger('warn', 'Update publish quality, No suitable video encoding quality found');
5891
- return;
5742
+ return this.logger('warn', `${tag} there are no encodings set.`);
5892
5743
  }
5893
5744
  const [codecInUse] = params.codecs;
5894
5745
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse.mimeType);
@@ -5930,54 +5781,12 @@ class Publisher {
5930
5781
  changed = true;
5931
5782
  }
5932
5783
  }
5933
- const activeLayers = params.encodings.filter((e) => e.active);
5784
+ const activeEncoders = params.encodings.filter((e) => e.active);
5934
5785
  if (!changed) {
5935
- this.logger('info', `Update publish quality, no change:`, activeLayers);
5936
- return;
5937
- }
5938
- await videoSender.setParameters(params);
5939
- this.logger('info', `Update publish quality, enabled rids:`, activeLayers);
5940
- };
5941
- /**
5942
- * Returns the result of the `RTCPeerConnection.getStats()` method
5943
- * @param selector
5944
- * @returns
5945
- */
5946
- this.getStats = (selector) => {
5947
- return this.pc.getStats(selector);
5948
- };
5949
- this.getCodecPreferences = (trackType, preferredCodec, codecPreferencesSource) => {
5950
- if (trackType === TrackType.VIDEO) {
5951
- return getPreferredCodecs('video', preferredCodec || 'vp8', undefined, codecPreferencesSource);
5952
- }
5953
- if (trackType === TrackType.AUDIO) {
5954
- const defaultAudioCodec = this.isRedEnabled ? 'red' : 'opus';
5955
- const codecToRemove = !this.isRedEnabled ? 'red' : undefined;
5956
- return getPreferredCodecs('audio', preferredCodec ?? defaultAudioCodec, codecToRemove, codecPreferencesSource);
5957
- }
5958
- };
5959
- this.onIceCandidate = (e) => {
5960
- const { candidate } = e;
5961
- if (!candidate) {
5962
- this.logger('debug', 'null ice candidate');
5963
- return;
5786
+ return this.logger('info', `${tag} no change:`, activeEncoders);
5964
5787
  }
5965
- this.sfuClient
5966
- .iceTrickle({
5967
- iceCandidate: getIceCandidate(candidate),
5968
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
5969
- })
5970
- .catch((err) => {
5971
- this.logger('warn', `ICETrickle failed`, err);
5972
- });
5973
- };
5974
- /**
5975
- * Sets the SFU client to use.
5976
- *
5977
- * @param sfuClient the SFU client to use.
5978
- */
5979
- this.setSfuClient = (sfuClient) => {
5980
- this.sfuClient = sfuClient;
5788
+ await sender.setParameters(params);
5789
+ this.logger('info', `${tag} enabled rids:`, activeEncoders);
5981
5790
  };
5982
5791
  /**
5983
5792
  * Restarts the ICE connection and renegotiates with the SFU.
@@ -5992,7 +5801,7 @@ class Publisher {
5992
5801
  await this.negotiate({ iceRestart: true });
5993
5802
  };
5994
5803
  this.onNegotiationNeeded = () => {
5995
- this.negotiate().catch((err) => {
5804
+ withoutConcurrency('publisher.negotiate', () => this.negotiate()).catch((err) => {
5996
5805
  this.logger('error', `Negotiation failed.`, err);
5997
5806
  this.onUnrecoverableError?.();
5998
5807
  });
@@ -6004,18 +5813,6 @@ class Publisher {
6004
5813
  */
6005
5814
  this.negotiate = async (options) => {
6006
5815
  const offer = await this.pc.createOffer(options);
6007
- if (offer.sdp) {
6008
- offer.sdp = toggleDtx(offer.sdp, this.isDtxEnabled);
6009
- if (this.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
6010
- offer.sdp = this.enableHighQualityAudio(offer.sdp);
6011
- }
6012
- if (this.isPublishing(TrackType.VIDEO)) {
6013
- // Hotfix for platforms that don't respect the ordered codec list
6014
- // (Firefox, Android, Linux, etc...).
6015
- // We remove all the codecs from the SDP except the one we want to use.
6016
- offer.sdp = this.removeUnpreferredCodecs(offer.sdp, TrackType.VIDEO);
6017
- }
6018
- }
6019
5816
  const trackInfos = this.getAnnouncedTracks(offer.sdp);
6020
5817
  if (trackInfos.length === 0) {
6021
5818
  throw new Error(`Can't negotiate without announcing any tracks`);
@@ -6034,238 +5831,121 @@ class Publisher {
6034
5831
  finally {
6035
5832
  this.isIceRestarting = false;
6036
5833
  }
6037
- this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(async (candidate) => {
6038
- try {
6039
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6040
- await this.pc.addIceCandidate(iceCandidate);
6041
- }
6042
- catch (e) {
6043
- this.logger('warn', `ICE candidate error`, e, candidate);
6044
- }
6045
- });
6046
- };
6047
- this.enableHighQualityAudio = (sdp) => {
6048
- const transceiver = this.transceiverCache.get(TrackType.SCREEN_SHARE_AUDIO);
6049
- if (!transceiver)
6050
- return sdp;
6051
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(TrackType.SCREEN_SHARE_AUDIO);
6052
- const mid = extractMid(transceiver, transceiverInitIndex, sdp);
6053
- return enableHighQualityAudio(sdp, mid);
5834
+ this.addTrickledIceCandidates();
6054
5835
  };
6055
5836
  /**
6056
5837
  * Returns a list of tracks that are currently being published.
6057
- *
6058
- * @internal
6059
- * @param sdp an optional SDP to extract the `mid` from.
6060
5838
  */
6061
- this.getAnnouncedTracks = (sdp) => {
6062
- sdp = sdp || this.pc.localDescription?.sdp;
6063
- return this.pc
6064
- .getTransceivers()
6065
- .filter((t) => t.direction === 'sendonly' && t.sender.track)
6066
- .map((transceiver) => {
6067
- let trackType;
6068
- this.transceiverCache.forEach((value, key) => {
6069
- if (value === transceiver)
6070
- trackType = key;
6071
- });
5839
+ this.getPublishedTracks = () => {
5840
+ const tracks = [];
5841
+ for (const { transceiver } of this.transceiverCache.items()) {
6072
5842
  const track = transceiver.sender.track;
6073
- let optimalLayers;
6074
- const isTrackLive = track.readyState === 'live';
6075
- if (isTrackLive) {
6076
- optimalLayers = this.computeLayers(trackType, track) || [];
6077
- this.trackLayersCache.set(trackType, optimalLayers);
6078
- }
6079
- else {
6080
- // we report the last known optimal layers for ended tracks
6081
- optimalLayers = this.trackLayersCache.get(trackType) || [];
6082
- this.logger('debug', `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`, optimalLayers);
6083
- }
6084
- const layers = optimalLayers.map((optimalLayer) => ({
6085
- rid: optimalLayer.rid || '',
6086
- bitrate: optimalLayer.maxBitrate || 0,
6087
- fps: optimalLayer.maxFramerate || 0,
6088
- quality: ridToVideoQuality(optimalLayer.rid || ''),
6089
- videoDimension: {
6090
- width: optimalLayer.width,
6091
- height: optimalLayer.height,
6092
- },
6093
- }));
6094
- const isAudioTrack = [
6095
- TrackType.AUDIO,
6096
- TrackType.SCREEN_SHARE_AUDIO,
6097
- ].includes(trackType);
6098
- const trackSettings = track.getSettings();
6099
- const isStereo = isAudioTrack && trackSettings.channelCount === 2;
6100
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(trackType);
6101
- return {
6102
- trackId: track.id,
6103
- layers: layers,
6104
- trackType,
6105
- mid: extractMid(transceiver, transceiverInitIndex, sdp),
6106
- stereo: isStereo,
6107
- dtx: isAudioTrack && this.isDtxEnabled,
6108
- red: isAudioTrack && this.isRedEnabled,
6109
- muted: !isTrackLive,
6110
- };
6111
- });
6112
- };
6113
- this.computeLayers = (trackType, track, opts) => {
6114
- const { settings } = this.state;
6115
- const targetResolution = settings?.video
6116
- .target_resolution;
6117
- const screenShareBitrate = settings?.screensharing.target_resolution?.bitrate;
6118
- const publishOpts = opts || this.publishOptsForTrack.get(trackType);
6119
- const codecInUse = opts?.forceCodec || getOptimalVideoCodec(opts?.preferredCodec);
6120
- return trackType === TrackType.VIDEO
6121
- ? findOptimalVideoLayers(track, targetResolution, codecInUse, publishOpts)
6122
- : trackType === TrackType.SCREEN_SHARE
6123
- ? findOptimalScreenSharingLayers(track, publishOpts, screenShareBitrate)
6124
- : undefined;
6125
- };
6126
- this.onIceCandidateError = (e) => {
6127
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6128
- `${e.errorCode}: ${e.errorText}`;
6129
- const iceState = this.pc.iceConnectionState;
6130
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6131
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6132
- };
6133
- this.onIceConnectionStateChange = () => {
6134
- const state = this.pc.iceConnectionState;
6135
- this.logger('debug', `ICE Connection state changed to`, state);
6136
- if (this.state.callingState === exports.CallingState.RECONNECTING)
6137
- return;
6138
- if (state === 'failed' || state === 'disconnected') {
6139
- this.logger('debug', `Attempting to restart ICE`);
6140
- this.restartIce().catch((e) => {
6141
- this.logger('error', `ICE restart error`, e);
6142
- this.onUnrecoverableError?.();
6143
- });
5843
+ if (track && track.readyState === 'live')
5844
+ tracks.push(track);
6144
5845
  }
6145
- };
6146
- this.onIceGatheringStateChange = () => {
6147
- this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
6148
- };
6149
- this.onSignalingStateChange = () => {
6150
- this.logger('debug', `Signaling state changed`, this.pc.signalingState);
6151
- };
6152
- this.logger = getLogger(['Publisher', logTag]);
6153
- this.pc = this.createPeerConnection(connectionConfig);
6154
- this.sfuClient = sfuClient;
6155
- this.state = state;
6156
- this.isDtxEnabled = isDtxEnabled;
6157
- this.isRedEnabled = isRedEnabled;
6158
- this.onUnrecoverableError = onUnrecoverableError;
6159
- this.unsubscribeOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6160
- if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
6161
- return;
6162
- this.restartIce().catch((err) => {
6163
- this.logger('warn', `ICERestart failed`, err);
6164
- this.onUnrecoverableError?.();
6165
- });
6166
- });
6167
- this.unsubscribeChangePublishQuality = dispatcher.on('changePublishQuality', ({ videoSenders }) => {
6168
- withoutConcurrency('publisher.changePublishQuality', async () => {
6169
- for (const videoSender of videoSenders) {
6170
- const { layers } = videoSender;
6171
- const enabledLayers = layers.filter((l) => l.active);
6172
- await this.changePublishQuality(enabledLayers);
6173
- }
6174
- }).catch((err) => {
6175
- this.logger('warn', 'Failed to change publish quality', err);
6176
- });
6177
- });
6178
- }
6179
- removeUnpreferredCodecs(sdp, trackType) {
6180
- const opts = this.publishOptsForTrack.get(trackType);
6181
- const forceSingleCodec = !!opts?.forceSingleCodec || isReactNative() || isFirefox();
6182
- if (!opts || !forceSingleCodec)
6183
- return sdp;
6184
- const codec = opts.forceCodec || getOptimalVideoCodec(opts.preferredCodec);
6185
- const orderedCodecs = this.getCodecPreferences(trackType, codec, 'sender');
6186
- if (!orderedCodecs || orderedCodecs.length === 0)
6187
- return sdp;
6188
- const transceiver = this.transceiverCache.get(trackType);
6189
- if (!transceiver)
6190
- return sdp;
6191
- const index = this.transceiverInitOrder.indexOf(trackType);
6192
- const mid = extractMid(transceiver, index, sdp);
6193
- const [codecToPreserve] = orderedCodecs;
6194
- return preserveCodec(sdp, mid, codecToPreserve);
6195
- }
6196
- }
6197
-
6198
- /**
6199
- * A wrapper around the `RTCPeerConnection` that handles the incoming
6200
- * media streams from the SFU.
6201
- *
6202
- * @internal
6203
- */
6204
- class Subscriber {
6205
- /**
6206
- * Constructs a new `Subscriber` instance.
6207
- *
6208
- * @param sfuClient the SFU client to use.
6209
- * @param dispatcher the dispatcher to use.
6210
- * @param state the state of the call.
6211
- * @param connectionConfig the connection configuration to use.
6212
- * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
6213
- * @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
6214
- * @param logTag a tag to use for logging.
6215
- */
6216
- constructor({ sfuClient, dispatcher, state, connectionConfig, onUnrecoverableError, logTag, }) {
6217
- this.isIceRestarting = false;
6218
- /**
6219
- * Creates a new `RTCPeerConnection` instance with the given configuration.
6220
- *
6221
- * @param connectionConfig the connection configuration to use.
6222
- */
6223
- this.createPeerConnection = (connectionConfig) => {
6224
- const pc = new RTCPeerConnection(connectionConfig);
6225
- pc.addEventListener('icecandidate', this.onIceCandidate);
6226
- pc.addEventListener('track', this.handleOnTrack);
6227
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6228
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6229
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
6230
- return pc;
6231
- };
6232
- /**
6233
- * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
6234
- */
6235
- this.close = () => {
6236
- this.detachEventHandlers();
6237
- this.pc.close();
5846
+ return tracks;
6238
5847
  };
6239
5848
  /**
6240
- * Detaches the event handlers from the `RTCPeerConnection`.
6241
- * This is useful when we want to replace the `RTCPeerConnection`
6242
- * instance with a new one (in case of migration).
5849
+ * Returns a list of tracks that are currently being published.
5850
+ * @param sdp an optional SDP to extract the `mid` from.
6243
5851
  */
6244
- this.detachEventHandlers = () => {
6245
- this.unregisterOnSubscriberOffer();
6246
- this.unregisterOnIceRestart();
6247
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
6248
- this.pc.removeEventListener('track', this.handleOnTrack);
6249
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6250
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6251
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5852
+ this.getAnnouncedTracks = (sdp) => {
5853
+ const trackInfos = [];
5854
+ for (const bundle of this.transceiverCache.items()) {
5855
+ const { transceiver, publishOption } = bundle;
5856
+ const track = transceiver.sender.track;
5857
+ if (!track)
5858
+ continue;
5859
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5860
+ }
5861
+ return trackInfos;
6252
5862
  };
6253
5863
  /**
6254
- * Returns the result of the `RTCPeerConnection.getStats()` method
6255
- * @param selector
6256
- * @returns
6257
- */
6258
- this.getStats = (selector) => {
6259
- return this.pc.getStats(selector);
5864
+ * Returns a list of tracks that are currently being published.
5865
+ * This method shall be used for the reconnection flow.
5866
+ * There we shouldn't announce the tracks that have been stopped due to a codec switch.
5867
+ */
5868
+ this.getAnnouncedTracksForReconnect = () => {
5869
+ const sdp = this.pc.localDescription?.sdp;
5870
+ const trackInfos = [];
5871
+ for (const publishOption of this.publishOptions) {
5872
+ const transceiver = this.transceiverCache.get(publishOption);
5873
+ if (!transceiver || !transceiver.sender.track)
5874
+ continue;
5875
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5876
+ }
5877
+ return trackInfos;
6260
5878
  };
6261
5879
  /**
6262
- * Sets the SFU client to use.
6263
- *
6264
- * @param sfuClient the SFU client to use.
5880
+ * Converts the given transceiver to a `TrackInfo` object.
6265
5881
  */
6266
- this.setSfuClient = (sfuClient) => {
6267
- this.sfuClient = sfuClient;
5882
+ this.toTrackInfo = (transceiver, publishOption, sdp) => {
5883
+ const track = transceiver.sender.track;
5884
+ const isTrackLive = track.readyState === 'live';
5885
+ const layers = isTrackLive
5886
+ ? computeVideoLayers(track, publishOption)
5887
+ : this.transceiverCache.getLayers(publishOption);
5888
+ this.transceiverCache.setLayers(publishOption, layers);
5889
+ const isAudioTrack = isAudioTrackType(publishOption.trackType);
5890
+ const isStereo = isAudioTrack && track.getSettings().channelCount === 2;
5891
+ const transceiverIndex = this.transceiverCache.indexOf(transceiver);
5892
+ const audioSettings = this.state.settings?.audio;
5893
+ return {
5894
+ trackId: track.id,
5895
+ layers: toVideoLayers(layers),
5896
+ trackType: publishOption.trackType,
5897
+ mid: extractMid(transceiver, transceiverIndex, sdp),
5898
+ stereo: isStereo,
5899
+ dtx: isAudioTrack && !!audioSettings?.opus_dtx_enabled,
5900
+ red: isAudioTrack && !!audioSettings?.redundant_coding_enabled,
5901
+ muted: !isTrackLive,
5902
+ codec: publishOption.codec,
5903
+ publishOptionId: publishOption.id,
5904
+ };
6268
5905
  };
5906
+ this.publishOptions = publishOptions;
5907
+ this.pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5908
+ this.on('iceRestart', (iceRestart) => {
5909
+ if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
5910
+ return;
5911
+ this.restartIce().catch((err) => {
5912
+ this.logger('warn', `ICERestart failed`, err);
5913
+ this.onUnrecoverableError?.();
5914
+ });
5915
+ });
5916
+ this.on('changePublishQuality', async (event) => {
5917
+ for (const videoSender of event.videoSenders) {
5918
+ await this.changePublishQuality(videoSender);
5919
+ }
5920
+ });
5921
+ this.on('changePublishOptions', (event) => {
5922
+ this.publishOptions = event.publishOptions;
5923
+ return this.syncPublishOptions();
5924
+ });
5925
+ }
5926
+ /**
5927
+ * Detaches the event handlers from the `RTCPeerConnection`.
5928
+ * This is useful when we want to replace the `RTCPeerConnection`
5929
+ * instance with a new one (in case of migration).
5930
+ */
5931
+ detachEventHandlers() {
5932
+ super.detachEventHandlers();
5933
+ this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5934
+ }
5935
+ }
5936
+
5937
+ /**
5938
+ * A wrapper around the `RTCPeerConnection` that handles the incoming
5939
+ * media streams from the SFU.
5940
+ *
5941
+ * @internal
5942
+ */
5943
+ class Subscriber extends BasePeerConnection {
5944
+ /**
5945
+ * Constructs a new `Subscriber` instance.
5946
+ */
5947
+ constructor(opts) {
5948
+ super(PeerType.SUBSCRIBER, opts);
6269
5949
  /**
6270
5950
  * Restarts the ICE connection and renegotiates with the SFU.
6271
5951
  */
@@ -6328,7 +6008,15 @@ class Subscriber {
6328
6008
  this.logger('error', `Unknown track type: ${rawTrackType}`);
6329
6009
  return;
6330
6010
  }
6011
+ // get the previous stream to dispose it later
6012
+ // usually this happens during migration, when the stream is replaced
6013
+ // with a new one but the old one is still in the state
6331
6014
  const previousStream = participantToUpdate[streamKindProp];
6015
+ // replace the previous stream with the new one, prevents flickering
6016
+ this.state.updateParticipant(participantToUpdate.sessionId, {
6017
+ [streamKindProp]: primaryStream,
6018
+ });
6019
+ // now, dispose the previous stream if it exists
6332
6020
  if (previousStream) {
6333
6021
  this.logger('info', `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
6334
6022
  previousStream.getTracks().forEach((t) => {
@@ -6336,24 +6024,6 @@ class Subscriber {
6336
6024
  previousStream.removeTrack(t);
6337
6025
  });
6338
6026
  }
6339
- this.state.updateParticipant(participantToUpdate.sessionId, {
6340
- [streamKindProp]: primaryStream,
6341
- });
6342
- };
6343
- this.onIceCandidate = (e) => {
6344
- const { candidate } = e;
6345
- if (!candidate) {
6346
- this.logger('debug', 'null ice candidate');
6347
- return;
6348
- }
6349
- this.sfuClient
6350
- .iceTrickle({
6351
- iceCandidate: getIceCandidate(candidate),
6352
- peerType: PeerType.SUBSCRIBER,
6353
- })
6354
- .catch((err) => {
6355
- this.logger('warn', `ICETrickle failed`, err);
6356
- });
6357
6027
  };
6358
6028
  this.negotiate = async (subscriberOffer) => {
6359
6029
  this.logger('info', `Received subscriberOffer`, subscriberOffer);
@@ -6361,15 +6031,7 @@ class Subscriber {
6361
6031
  type: 'offer',
6362
6032
  sdp: subscriberOffer.sdp,
6363
6033
  });
6364
- this.sfuClient.iceTrickleBuffer.subscriberCandidates.subscribe(async (candidate) => {
6365
- try {
6366
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6367
- await this.pc.addIceCandidate(iceCandidate);
6368
- }
6369
- catch (e) {
6370
- this.logger('warn', `ICE candidate error`, [e, candidate]);
6371
- }
6372
- });
6034
+ this.addTrickledIceCandidates();
6373
6035
  const answer = await this.pc.createAnswer();
6374
6036
  await this.pc.setLocalDescription(answer);
6375
6037
  await this.sfuClient.sendAnswer({
@@ -6378,56 +6040,21 @@ class Subscriber {
6378
6040
  });
6379
6041
  this.isIceRestarting = false;
6380
6042
  };
6381
- this.onIceConnectionStateChange = () => {
6382
- const state = this.pc.iceConnectionState;
6383
- this.logger('debug', `ICE connection state changed`, state);
6384
- if (this.state.callingState === exports.CallingState.RECONNECTING)
6385
- return;
6386
- // do nothing when ICE is restarting
6387
- if (this.isIceRestarting)
6388
- return;
6389
- if (state === 'failed' || state === 'disconnected') {
6390
- this.logger('debug', `Attempting to restart ICE`);
6391
- this.restartIce().catch((e) => {
6392
- this.logger('error', `ICE restart failed`, e);
6393
- this.onUnrecoverableError?.();
6394
- });
6395
- }
6396
- };
6397
- this.onIceGatheringStateChange = () => {
6398
- this.logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
6399
- };
6400
- this.onIceCandidateError = (e) => {
6401
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6402
- `${e.errorCode}: ${e.errorText}`;
6403
- const iceState = this.pc.iceConnectionState;
6404
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6405
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6406
- };
6407
- this.logger = getLogger(['Subscriber', logTag]);
6408
- this.sfuClient = sfuClient;
6409
- this.state = state;
6410
- this.onUnrecoverableError = onUnrecoverableError;
6411
- this.pc = this.createPeerConnection(connectionConfig);
6412
- const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
6413
- this.unregisterOnSubscriberOffer = dispatcher.on('subscriberOffer', (subscriberOffer) => {
6414
- withoutConcurrency(subscriberOfferConcurrencyTag, () => {
6415
- return this.negotiate(subscriberOffer);
6416
- }).catch((err) => {
6043
+ this.pc.addEventListener('track', this.handleOnTrack);
6044
+ this.on('subscriberOffer', async (subscriberOffer) => {
6045
+ return this.negotiate(subscriberOffer).catch((err) => {
6417
6046
  this.logger('error', `Negotiation failed.`, err);
6418
6047
  });
6419
6048
  });
6420
- const iceRestartConcurrencyTag = Symbol('iceRestart');
6421
- this.unregisterOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6422
- withoutConcurrency(iceRestartConcurrencyTag, async () => {
6423
- if (iceRestart.peerType !== PeerType.SUBSCRIBER)
6424
- return;
6425
- await this.restartIce();
6426
- }).catch((err) => {
6427
- this.logger('error', `ICERestart failed`, err);
6428
- this.onUnrecoverableError?.();
6429
- });
6430
- });
6049
+ }
6050
+ /**
6051
+ * Detaches the event handlers from the `RTCPeerConnection`.
6052
+ * This is useful when we want to replace the `RTCPeerConnection`
6053
+ * instance with a new one (in case of migration).
6054
+ */
6055
+ detachEventHandlers() {
6056
+ super.detachEventHandlers();
6057
+ this.pc.removeEventListener('track', this.handleOnTrack);
6431
6058
  }
6432
6059
  }
6433
6060
 
@@ -6459,6 +6086,16 @@ const createWebSocketSignalChannel = (opts) => {
6459
6086
  return ws;
6460
6087
  };
6461
6088
 
6089
+ const toRtcConfiguration = (config) => {
6090
+ return {
6091
+ iceServers: config.map((ice) => ({
6092
+ urls: ice.urls,
6093
+ username: ice.username,
6094
+ credential: ice.password,
6095
+ })),
6096
+ };
6097
+ };
6098
+
6462
6099
  /**
6463
6100
  * Saving a long-lived reference to a promise that can reject can be unsafe,
6464
6101
  * since rejecting the promise causes an unhandled rejection error (even if the
@@ -6743,6 +6380,7 @@ class StreamSfuClient {
6743
6380
  clearTimeout(this.migrateAwayTimeout);
6744
6381
  this.abortController.abort();
6745
6382
  this.migrationTask?.resolve();
6383
+ this.iceTrickleBuffer.dispose();
6746
6384
  };
6747
6385
  this.leaveAndClose = async (reason) => {
6748
6386
  await this.joinTask;
@@ -6775,13 +6413,9 @@ class StreamSfuClient {
6775
6413
  await this.joinTask;
6776
6414
  return retryable(() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6777
6415
  };
6778
- this.updateMuteState = async (trackType, muted) => {
6416
+ this.updateMuteStates = async (muteStates) => {
6779
6417
  await this.joinTask;
6780
- return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
6781
- };
6782
- this.updateMuteStates = async (data) => {
6783
- await this.joinTask;
6784
- return retryable(() => this.rpc.updateMuteStates({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6418
+ return retryable(() => this.rpc.updateMuteStates({ muteStates, sessionId: this.sessionId }), this.abortController.signal);
6785
6419
  };
6786
6420
  this.sendStats = async (stats) => {
6787
6421
  await this.joinTask;
@@ -6961,16 +6595,6 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
6961
6595
  */
6962
6596
  StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
6963
6597
 
6964
- const toRtcConfiguration = (config) => {
6965
- return {
6966
- iceServers: config.map((ice) => ({
6967
- urls: ice.urls,
6968
- username: ice.username,
6969
- credential: ice.password,
6970
- })),
6971
- };
6972
- };
6973
-
6974
6598
  /**
6975
6599
  * Event handler that watched the delivery of `call.accepted`.
6976
6600
  * Once the event is received, the call is joined.
@@ -7189,6 +6813,21 @@ const handleRemoteSoftMute = (call) => {
7189
6813
  });
7190
6814
  };
7191
6815
 
6816
+ /**
6817
+ * Adds unique values to an array.
6818
+ *
6819
+ * @param arr the array to add to.
6820
+ * @param values the values to add.
6821
+ */
6822
+ const pushToIfMissing = (arr, ...values) => {
6823
+ for (const v of values) {
6824
+ if (!arr.includes(v)) {
6825
+ arr.push(v);
6826
+ }
6827
+ }
6828
+ return arr;
6829
+ };
6830
+
7192
6831
  /**
7193
6832
  * An event responder which handles the `participantJoined` event.
7194
6833
  */
@@ -7254,7 +6893,7 @@ const watchTrackPublished = (state) => {
7254
6893
  }
7255
6894
  else {
7256
6895
  state.updateParticipant(sessionId, (p) => ({
7257
- publishedTracks: [...p.publishedTracks, type].filter(unique),
6896
+ publishedTracks: pushToIfMissing([...p.publishedTracks], type),
7258
6897
  }));
7259
6898
  }
7260
6899
  };
@@ -7279,7 +6918,6 @@ const watchTrackUnpublished = (state) => {
7279
6918
  }
7280
6919
  };
7281
6920
  };
7282
- const unique = (v, i, arr) => arr.indexOf(v) === i;
7283
6921
  /**
7284
6922
  * Reconciles orphaned tracks (if any) for the given participant.
7285
6923
  *
@@ -7429,6 +7067,38 @@ const getSdkVersion = (sdk) => {
7429
7067
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7430
7068
  };
7431
7069
 
7070
+ /**
7071
+ * Checks whether the current browser is Safari.
7072
+ */
7073
+ const isSafari = () => {
7074
+ if (typeof navigator === 'undefined')
7075
+ return false;
7076
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
7077
+ };
7078
+ /**
7079
+ * Checks whether the current browser is Firefox.
7080
+ */
7081
+ const isFirefox = () => {
7082
+ if (typeof navigator === 'undefined')
7083
+ return false;
7084
+ return navigator.userAgent?.includes('Firefox');
7085
+ };
7086
+ /**
7087
+ * Checks whether the current browser is Google Chrome.
7088
+ */
7089
+ const isChrome = () => {
7090
+ if (typeof navigator === 'undefined')
7091
+ return false;
7092
+ return navigator.userAgent?.includes('Chrome');
7093
+ };
7094
+
7095
+ var browsers = /*#__PURE__*/Object.freeze({
7096
+ __proto__: null,
7097
+ isChrome: isChrome,
7098
+ isFirefox: isFirefox,
7099
+ isSafari: isSafari
7100
+ });
7101
+
7432
7102
  /**
7433
7103
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
7434
7104
  */
@@ -7445,12 +7115,12 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7445
7115
  return undefined;
7446
7116
  }
7447
7117
  };
7448
- const getStatsForStream = async (kind, mediaStream) => {
7118
+ const getStatsForStream = async (kind, tracks) => {
7449
7119
  const pc = kind === 'subscriber' ? subscriber : publisher;
7450
7120
  if (!pc)
7451
7121
  return [];
7452
7122
  const statsForStream = [];
7453
- for (let track of mediaStream.getTracks()) {
7123
+ for (const track of tracks) {
7454
7124
  const report = await pc.getStats(track);
7455
7125
  const stats = transform(report, {
7456
7126
  // @ts-ignore
@@ -7475,26 +7145,24 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7475
7145
  */
7476
7146
  const run = async () => {
7477
7147
  const participantStats = {};
7478
- const sessionIds = new Set(sessionIdsToTrack);
7479
- if (sessionIds.size > 0) {
7480
- for (let participant of state.participants) {
7148
+ if (sessionIdsToTrack.size > 0) {
7149
+ const sessionIds = new Set(sessionIdsToTrack);
7150
+ for (const participant of state.participants) {
7481
7151
  if (!sessionIds.has(participant.sessionId))
7482
7152
  continue;
7483
- const kind = participant.isLocalParticipant
7484
- ? 'publisher'
7485
- : 'subscriber';
7153
+ const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant;
7154
+ const kind = isLocalParticipant ? 'publisher' : 'subscriber';
7486
7155
  try {
7487
- const mergedStream = new MediaStream([
7488
- ...(participant.videoStream?.getVideoTracks() || []),
7489
- ...(participant.audioStream?.getAudioTracks() || []),
7490
- ]);
7491
- participantStats[participant.sessionId] = await getStatsForStream(kind, mergedStream);
7492
- mergedStream.getTracks().forEach((t) => {
7493
- mergedStream.removeTrack(t);
7494
- });
7156
+ const tracks = isLocalParticipant
7157
+ ? publisher?.getPublishedTracks() || []
7158
+ : [
7159
+ ...(videoStream?.getVideoTracks() || []),
7160
+ ...(audioStream?.getAudioTracks() || []),
7161
+ ];
7162
+ participantStats[sessionId] = await getStatsForStream(kind, tracks);
7495
7163
  }
7496
7164
  catch (e) {
7497
- logger('error', `Failed to collect stats for ${kind} of ${participant.userId}`, e);
7165
+ logger('warn', `Failed to collect ${kind} stats for ${userId}`, e);
7498
7166
  }
7499
7167
  }
7500
7168
  }
@@ -7504,6 +7172,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7504
7172
  .then((report) => transform(report, {
7505
7173
  kind: 'subscriber',
7506
7174
  trackKind: 'video',
7175
+ publisher,
7507
7176
  }))
7508
7177
  .then(aggregate),
7509
7178
  publisher
@@ -7512,6 +7181,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7512
7181
  .then((report) => transform(report, {
7513
7182
  kind: 'publisher',
7514
7183
  trackKind: 'video',
7184
+ publisher,
7515
7185
  }))
7516
7186
  .then(aggregate)
7517
7187
  : getEmptyStats(),
@@ -7560,7 +7230,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7560
7230
  * @param opts the transform options.
7561
7231
  */
7562
7232
  const transform = (report, opts) => {
7563
- const { trackKind, kind } = opts;
7233
+ const { trackKind, kind, publisher } = opts;
7564
7234
  const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
7565
7235
  const stats = flatten(report);
7566
7236
  const streams = stats
@@ -7576,6 +7246,16 @@ const transform = (report, opts) => {
7576
7246
  s.id === transport.selectedCandidatePairId);
7577
7247
  roundTripTime = candidatePair?.currentRoundTripTime;
7578
7248
  }
7249
+ let trackType;
7250
+ if (kind === 'publisher' && publisher) {
7251
+ const firefox = isFirefox();
7252
+ const mediaSource = stats.find((s) => s.type === 'media-source' &&
7253
+ // Firefox doesn't have mediaSourceId, so we need to guess the media source
7254
+ (firefox ? true : s.id === rtcStreamStats.mediaSourceId));
7255
+ if (mediaSource) {
7256
+ trackType = publisher.getTrackType(mediaSource.trackIdentifier);
7257
+ }
7258
+ }
7579
7259
  return {
7580
7260
  bytesSent: rtcStreamStats.bytesSent,
7581
7261
  bytesReceived: rtcStreamStats.bytesReceived,
@@ -7586,10 +7266,12 @@ const transform = (report, opts) => {
7586
7266
  framesPerSecond: rtcStreamStats.framesPerSecond,
7587
7267
  jitter: rtcStreamStats.jitter,
7588
7268
  kind: rtcStreamStats.kind,
7269
+ mediaSourceId: rtcStreamStats.mediaSourceId,
7589
7270
  // @ts-ignore: available in Chrome only, TS doesn't recognize this
7590
7271
  qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
7591
7272
  rid: rtcStreamStats.rid,
7592
7273
  ssrc: rtcStreamStats.ssrc,
7274
+ trackType,
7593
7275
  };
7594
7276
  });
7595
7277
  return {
@@ -7610,6 +7292,7 @@ const getEmptyStats = (stats) => {
7610
7292
  highestFrameHeight: 0,
7611
7293
  highestFramesPerSecond: 0,
7612
7294
  codec: '',
7295
+ codecPerTrackType: {},
7613
7296
  timestamp: Date.now(),
7614
7297
  };
7615
7298
  };
@@ -7645,6 +7328,12 @@ const aggregate = (stats) => {
7645
7328
  report.averageRoundTripTimeInMs = Math.round((report.averageRoundTripTimeInMs / streams.length) * 1000);
7646
7329
  // we take the first codec we find, as it should be the same for all streams
7647
7330
  report.codec = streams[0].codec || '';
7331
+ report.codecPerTrackType = streams.reduce((acc, stream) => {
7332
+ if (stream.trackType) {
7333
+ acc[stream.trackType] = stream.codec || '';
7334
+ }
7335
+ return acc;
7336
+ }, {});
7648
7337
  }
7649
7338
  const qualityLimitationReason = [
7650
7339
  qualityLimitationReasons.has('cpu') && 'cpu',
@@ -7656,7 +7345,135 @@ const aggregate = (stats) => {
7656
7345
  if (qualityLimitationReason) {
7657
7346
  report.qualityLimitationReasons = qualityLimitationReason;
7658
7347
  }
7659
- return report;
7348
+ return report;
7349
+ };
7350
+
7351
+ const version = "1.15.0";
7352
+ const [major, minor, patch] = version.split('.');
7353
+ let sdkInfo = {
7354
+ type: SdkType.PLAIN_JAVASCRIPT,
7355
+ major,
7356
+ minor,
7357
+ patch,
7358
+ };
7359
+ let osInfo;
7360
+ let deviceInfo;
7361
+ let webRtcInfo;
7362
+ let deviceState = { oneofKind: undefined };
7363
+ const setSdkInfo = (info) => {
7364
+ sdkInfo = info;
7365
+ };
7366
+ const getSdkInfo = () => {
7367
+ return sdkInfo;
7368
+ };
7369
+ const setOSInfo = (info) => {
7370
+ osInfo = info;
7371
+ };
7372
+ const getOSInfo = () => {
7373
+ return osInfo;
7374
+ };
7375
+ const setDeviceInfo = (info) => {
7376
+ deviceInfo = info;
7377
+ };
7378
+ const getDeviceInfo = () => {
7379
+ return deviceInfo;
7380
+ };
7381
+ const getWebRTCInfo = () => {
7382
+ return webRtcInfo;
7383
+ };
7384
+ const setWebRTCInfo = (info) => {
7385
+ webRtcInfo = info;
7386
+ };
7387
+ const setThermalState = (state) => {
7388
+ if (!osInfo) {
7389
+ deviceState = { oneofKind: undefined };
7390
+ return;
7391
+ }
7392
+ if (osInfo.name === 'android') {
7393
+ const thermalState = AndroidThermalState[state] ||
7394
+ AndroidThermalState.UNSPECIFIED;
7395
+ deviceState = {
7396
+ oneofKind: 'android',
7397
+ android: {
7398
+ thermalState,
7399
+ isPowerSaverMode: deviceState?.oneofKind === 'android' &&
7400
+ deviceState.android.isPowerSaverMode,
7401
+ },
7402
+ };
7403
+ }
7404
+ if (osInfo.name.toLowerCase() === 'ios') {
7405
+ const thermalState = AppleThermalState[state] ||
7406
+ AppleThermalState.UNSPECIFIED;
7407
+ deviceState = {
7408
+ oneofKind: 'apple',
7409
+ apple: {
7410
+ thermalState,
7411
+ isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
7412
+ deviceState.apple.isLowPowerModeEnabled,
7413
+ },
7414
+ };
7415
+ }
7416
+ };
7417
+ const setPowerState = (powerMode) => {
7418
+ if (!osInfo) {
7419
+ deviceState = { oneofKind: undefined };
7420
+ return;
7421
+ }
7422
+ if (osInfo.name === 'android') {
7423
+ deviceState = {
7424
+ oneofKind: 'android',
7425
+ android: {
7426
+ thermalState: deviceState?.oneofKind === 'android'
7427
+ ? deviceState.android.thermalState
7428
+ : AndroidThermalState.UNSPECIFIED,
7429
+ isPowerSaverMode: powerMode,
7430
+ },
7431
+ };
7432
+ }
7433
+ if (osInfo.name.toLowerCase() === 'ios') {
7434
+ deviceState = {
7435
+ oneofKind: 'apple',
7436
+ apple: {
7437
+ thermalState: deviceState?.oneofKind === 'apple'
7438
+ ? deviceState.apple.thermalState
7439
+ : AppleThermalState.UNSPECIFIED,
7440
+ isLowPowerModeEnabled: powerMode,
7441
+ },
7442
+ };
7443
+ }
7444
+ };
7445
+ const getDeviceState = () => {
7446
+ return deviceState;
7447
+ };
7448
+ const getClientDetails = () => {
7449
+ if (isReactNative()) {
7450
+ // Since RN doesn't support web, sharing browser info is not required
7451
+ return {
7452
+ sdk: getSdkInfo(),
7453
+ os: getOSInfo(),
7454
+ device: getDeviceInfo(),
7455
+ };
7456
+ }
7457
+ const userAgent = new uaParserJs.UAParser(navigator.userAgent);
7458
+ const { browser, os, device, cpu } = userAgent.getResult();
7459
+ return {
7460
+ sdk: getSdkInfo(),
7461
+ browser: {
7462
+ name: browser.name || navigator.userAgent,
7463
+ version: browser.version || '',
7464
+ },
7465
+ os: {
7466
+ name: os.name || '',
7467
+ version: os.version || '',
7468
+ architecture: cpu.architecture || '',
7469
+ },
7470
+ device: {
7471
+ name: [device.vendor, device.model, device.type]
7472
+ .filter(Boolean)
7473
+ .join(' '),
7474
+ version: '',
7475
+ },
7476
+ };
7660
7477
  };
7661
7478
 
7662
7479
  class SfuStatsReporter {
@@ -7692,8 +7509,28 @@ class SfuStatsReporter {
7692
7509
  });
7693
7510
  });
7694
7511
  };
7695
- this.sendTelemetryData = async (telemetryData) => {
7696
- return this.run(telemetryData);
7512
+ this.sendConnectionTime = (connectionTimeSeconds) => {
7513
+ this.sendTelemetryData({
7514
+ data: {
7515
+ oneofKind: 'connectionTimeSeconds',
7516
+ connectionTimeSeconds,
7517
+ },
7518
+ });
7519
+ };
7520
+ this.sendReconnectionTime = (strategy, timeSeconds) => {
7521
+ this.sendTelemetryData({
7522
+ data: {
7523
+ oneofKind: 'reconnection',
7524
+ reconnection: { strategy, timeSeconds },
7525
+ },
7526
+ });
7527
+ };
7528
+ this.sendTelemetryData = (telemetryData) => {
7529
+ // intentionally not awaiting the promise here
7530
+ // to avoid impeding with the ongoing actions.
7531
+ this.run(telemetryData).catch((err) => {
7532
+ this.logger('warn', 'Failed to send telemetry data', err);
7533
+ });
7697
7534
  };
7698
7535
  this.run = async (telemetryData) => {
7699
7536
  const [subscriberStats, publisherStats] = await Promise.all([
@@ -8261,6 +8098,25 @@ class PermissionsContext {
8261
8098
  this.hasPermission = (permission) => {
8262
8099
  return this.permissions.includes(permission);
8263
8100
  };
8101
+ /**
8102
+ * Helper method that checks whether the current user has the permission
8103
+ * to publish the given track type.
8104
+ */
8105
+ this.canPublish = (trackType) => {
8106
+ switch (trackType) {
8107
+ case TrackType.AUDIO:
8108
+ return this.hasPermission(OwnCapability.SEND_AUDIO);
8109
+ case TrackType.VIDEO:
8110
+ return this.hasPermission(OwnCapability.SEND_VIDEO);
8111
+ case TrackType.SCREEN_SHARE:
8112
+ case TrackType.SCREEN_SHARE_AUDIO:
8113
+ return this.hasPermission(OwnCapability.SCREENSHARE);
8114
+ case TrackType.UNSPECIFIED:
8115
+ return false;
8116
+ default:
8117
+ ensureExhausted(trackType, 'Unknown track type');
8118
+ }
8119
+ };
8264
8120
  /**
8265
8121
  * Checks if the current user can request a specific permission
8266
8122
  * within the call.
@@ -8899,36 +8755,42 @@ class InputMediaDeviceManager {
8899
8755
  }
8900
8756
  });
8901
8757
  }
8758
+ publishStream(stream) {
8759
+ return this.call.publish(stream, this.trackType);
8760
+ }
8761
+ stopPublishStream() {
8762
+ return this.call.stopPublish(this.trackType);
8763
+ }
8902
8764
  getTracks() {
8903
8765
  return this.state.mediaStream?.getTracks() ?? [];
8904
8766
  }
8905
8767
  async muteStream(stopTracks = true) {
8906
- if (!this.state.mediaStream)
8768
+ const mediaStream = this.state.mediaStream;
8769
+ if (!mediaStream)
8907
8770
  return;
8908
8771
  this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
8909
8772
  if (this.call.state.callingState === exports.CallingState.JOINED) {
8910
- await this.stopPublishStream(stopTracks);
8773
+ await this.stopPublishStream();
8911
8774
  }
8912
8775
  this.muteLocalStream(stopTracks);
8913
8776
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
8914
8777
  if (allEnded) {
8915
- if (this.state.mediaStream &&
8916
- // @ts-expect-error release() is present in react-native-webrtc
8917
- typeof this.state.mediaStream.release === 'function') {
8778
+ // @ts-expect-error release() is present in react-native-webrtc
8779
+ if (typeof mediaStream.release === 'function') {
8918
8780
  // @ts-expect-error called to dispose the stream in RN
8919
- this.state.mediaStream.release();
8781
+ mediaStream.release();
8920
8782
  }
8921
8783
  this.state.setMediaStream(undefined, undefined);
8922
8784
  this.filters.forEach((entry) => entry.stop?.());
8923
8785
  }
8924
8786
  }
8925
- muteTracks() {
8787
+ disableTracks() {
8926
8788
  this.getTracks().forEach((track) => {
8927
8789
  if (track.enabled)
8928
8790
  track.enabled = false;
8929
8791
  });
8930
8792
  }
8931
- unmuteTracks() {
8793
+ enableTracks() {
8932
8794
  this.getTracks().forEach((track) => {
8933
8795
  if (!track.enabled)
8934
8796
  track.enabled = true;
@@ -8948,7 +8810,7 @@ class InputMediaDeviceManager {
8948
8810
  this.stopTracks();
8949
8811
  }
8950
8812
  else {
8951
- this.muteTracks();
8813
+ this.disableTracks();
8952
8814
  }
8953
8815
  }
8954
8816
  async unmuteStream() {
@@ -8958,7 +8820,7 @@ class InputMediaDeviceManager {
8958
8820
  if (this.state.mediaStream &&
8959
8821
  this.getTracks().every((t) => t.readyState === 'live')) {
8960
8822
  stream = this.state.mediaStream;
8961
- this.unmuteTracks();
8823
+ this.enableTracks();
8962
8824
  }
8963
8825
  else {
8964
8826
  const defaultConstraints = this.state.defaultConstraints;
@@ -9052,9 +8914,22 @@ class InputMediaDeviceManager {
9052
8914
  await this.disable();
9053
8915
  }
9054
8916
  };
9055
- this.getTracks().forEach((track) => {
8917
+ const createTrackMuteHandler = (muted) => () => {
8918
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
8919
+ this.logger('warn', 'Error while notifying track mute state', err);
8920
+ });
8921
+ };
8922
+ stream.getTracks().forEach((track) => {
8923
+ const muteHandler = createTrackMuteHandler(true);
8924
+ const unmuteHandler = createTrackMuteHandler(false);
8925
+ track.addEventListener('mute', muteHandler);
8926
+ track.addEventListener('unmute', unmuteHandler);
9056
8927
  track.addEventListener('ended', handleTrackEnded);
9057
- this.subscriptions.push(() => track.removeEventListener('ended', handleTrackEnded));
8928
+ this.subscriptions.push(() => {
8929
+ track.removeEventListener('mute', muteHandler);
8930
+ track.removeEventListener('unmute', unmuteHandler);
8931
+ track.removeEventListener('ended', handleTrackEnded);
8932
+ });
9058
8933
  });
9059
8934
  }
9060
8935
  }
@@ -9078,8 +8953,8 @@ class InputMediaDeviceManager {
9078
8953
  await this.statusChangeSettled();
9079
8954
  let isDeviceDisconnected = false;
9080
8955
  let isDeviceReplaced = false;
9081
- const currentDevice = this.findDeviceInList(currentDevices, deviceId);
9082
- const prevDevice = this.findDeviceInList(prevDevices, deviceId);
8956
+ const currentDevice = this.findDevice(currentDevices, deviceId);
8957
+ const prevDevice = this.findDevice(prevDevices, deviceId);
9083
8958
  if (!currentDevice && prevDevice) {
9084
8959
  isDeviceDisconnected = true;
9085
8960
  }
@@ -9109,8 +8984,9 @@ class InputMediaDeviceManager {
9109
8984
  }
9110
8985
  }));
9111
8986
  }
9112
- findDeviceInList(devices, deviceId) {
9113
- return devices.find((d) => d.deviceId === deviceId && d.kind === this.mediaDeviceKind);
8987
+ findDevice(devices, deviceId) {
8988
+ const kind = this.mediaDeviceKind;
8989
+ return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
9114
8990
  }
9115
8991
  }
9116
8992
 
@@ -9374,14 +9250,35 @@ class CameraManager extends InputMediaDeviceManager {
9374
9250
  }
9375
9251
  }
9376
9252
  /**
9377
- * Sets the preferred codec for encoding the video.
9253
+ * Applies the video settings to the camera.
9378
9254
  *
9379
- * @internal internal use only, not part of the public API.
9380
- * @deprecated use {@link call.updatePublishOptions} instead.
9381
- * @param codec the codec to use for encoding the video.
9255
+ * @param settings the video settings to apply.
9256
+ * @param publish whether to publish the stream after applying the settings.
9382
9257
  */
9383
- setPreferredCodec(codec) {
9384
- this.call.updatePublishOptions({ preferredCodec: codec });
9258
+ async apply(settings, publish) {
9259
+ const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
9260
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9261
+ if (hasPublishedVideo || !hasPermission)
9262
+ return;
9263
+ // Wait for any in progress camera operation
9264
+ await this.statusChangeSettled();
9265
+ const { target_resolution, camera_facing, camera_default_on } = settings;
9266
+ await this.selectTargetResolution(target_resolution);
9267
+ // Set camera direction if it's not yet set
9268
+ if (!this.state.direction && !this.state.selectedDevice) {
9269
+ this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
9270
+ }
9271
+ if (!publish)
9272
+ return;
9273
+ const { mediaStream } = this.state;
9274
+ if (this.enabled && mediaStream) {
9275
+ // The camera is already enabled (e.g. lobby screen). Publish the stream
9276
+ await this.publishStream(mediaStream);
9277
+ }
9278
+ else if (this.state.status === undefined && camera_default_on) {
9279
+ // Start camera if backend config specifies, and there is no local setting
9280
+ await this.enable();
9281
+ }
9385
9282
  }
9386
9283
  getDevices() {
9387
9284
  return getVideoDevices();
@@ -9399,12 +9296,6 @@ class CameraManager extends InputMediaDeviceManager {
9399
9296
  }
9400
9297
  return getVideoStream(constraints);
9401
9298
  }
9402
- publishStream(stream) {
9403
- return this.call.publishVideoStream(stream);
9404
- }
9405
- stopPublishStream(stopTracks) {
9406
- return this.call.stopPublish(TrackType.VIDEO, stopTracks);
9407
- }
9408
9299
  }
9409
9300
 
9410
9301
  class MicrophoneManagerState extends InputMediaDeviceManagerState {
@@ -9732,18 +9623,37 @@ class MicrophoneManager extends InputMediaDeviceManager {
9732
9623
  this.speakingWhileMutedNotificationEnabled = false;
9733
9624
  await this.stopSpeakingWhileMutedDetection();
9734
9625
  }
9626
+ /**
9627
+ * Applies the audio settings to the microphone.
9628
+ * @param settings the audio settings to apply.
9629
+ * @param publish whether to publish the stream after applying the settings.
9630
+ */
9631
+ async apply(settings, publish) {
9632
+ if (!publish)
9633
+ return;
9634
+ const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
9635
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9636
+ if (hasPublishedAudio || !hasPermission)
9637
+ return;
9638
+ // Wait for any in progress mic operation
9639
+ await this.statusChangeSettled();
9640
+ // Publish media stream that was set before we joined
9641
+ const { mediaStream } = this.state;
9642
+ if (this.enabled && mediaStream) {
9643
+ // The mic is already enabled (e.g. lobby screen). Publish the stream
9644
+ await this.publishStream(mediaStream);
9645
+ }
9646
+ else if (this.state.status === undefined && settings.mic_default_on) {
9647
+ // Start mic if backend config specifies, and there is no local setting
9648
+ await this.enable();
9649
+ }
9650
+ }
9735
9651
  getDevices() {
9736
9652
  return getAudioDevices();
9737
9653
  }
9738
9654
  getStream(constraints) {
9739
9655
  return getAudioStream(constraints);
9740
9656
  }
9741
- publishStream(stream) {
9742
- return this.call.publishAudioStream(stream);
9743
- }
9744
- stopPublishStream(stopTracks) {
9745
- return this.call.stopPublish(TrackType.AUDIO, stopTracks);
9746
- }
9747
9657
  async startSpeakingWhileMutedDetection(deviceId) {
9748
9658
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
9749
9659
  await this.stopSpeakingWhileMutedDetection();
@@ -9863,7 +9773,7 @@ class ScreenShareManager extends InputMediaDeviceManager {
9863
9773
  async disableScreenShareAudio() {
9864
9774
  this.state.setAudioEnabled(false);
9865
9775
  if (this.call.publisher?.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
9866
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, true);
9776
+ await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO);
9867
9777
  }
9868
9778
  }
9869
9779
  /**
@@ -9889,12 +9799,8 @@ class ScreenShareManager extends InputMediaDeviceManager {
9889
9799
  }
9890
9800
  return getScreenShareStream(constraints);
9891
9801
  }
9892
- publishStream(stream) {
9893
- return this.call.publishScreenShareStream(stream);
9894
- }
9895
- async stopPublishStream(stopTracks) {
9896
- await this.call.stopPublish(TrackType.SCREEN_SHARE, stopTracks);
9897
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, stopTracks);
9802
+ async stopPublishStream() {
9803
+ return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
9898
9804
  }
9899
9805
  /**
9900
9806
  * Overrides the default `select` method to throw an error.
@@ -10104,6 +10010,112 @@ class Call {
10104
10010
  */
10105
10011
  this.leaveCallHooks = new Set();
10106
10012
  this.streamClientEventHandlers = new Map();
10013
+ this.setup = async () => {
10014
+ await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
10015
+ if (this.initialized)
10016
+ return;
10017
+ this.leaveCallHooks.add(this.on('all', (event) => {
10018
+ // update state with the latest event data
10019
+ this.state.updateFromEvent(event);
10020
+ }));
10021
+ this.leaveCallHooks.add(this.on('changePublishOptions', (event) => {
10022
+ this.currentPublishOptions = event.publishOptions;
10023
+ }));
10024
+ this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
10025
+ this.registerEffects();
10026
+ this.registerReconnectHandlers();
10027
+ if (this.state.callingState === exports.CallingState.LEFT) {
10028
+ this.state.setCallingState(exports.CallingState.IDLE);
10029
+ }
10030
+ this.initialized = true;
10031
+ });
10032
+ };
10033
+ this.registerEffects = () => {
10034
+ this.leaveCallHooks.add(
10035
+ // handles updating the permissions context when the settings change.
10036
+ createSubscription(this.state.settings$, (settings) => {
10037
+ if (!settings)
10038
+ return;
10039
+ this.permissionsContext.setCallSettings(settings);
10040
+ }));
10041
+ this.leaveCallHooks.add(
10042
+ // handle the case when the user permissions are modified.
10043
+ createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
10044
+ this.leaveCallHooks.add(
10045
+ // handles the case when the user is blocked by the call owner.
10046
+ createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
10047
+ if (!blockedUserIds || blockedUserIds.length === 0)
10048
+ return;
10049
+ const currentUserId = this.currentUserId;
10050
+ if (currentUserId && blockedUserIds.includes(currentUserId)) {
10051
+ this.logger('info', 'Leaving call because of being blocked');
10052
+ await this.leave({ reason: 'user blocked' }).catch((err) => {
10053
+ this.logger('error', 'Error leaving call after being blocked', err);
10054
+ });
10055
+ }
10056
+ }));
10057
+ this.leaveCallHooks.add(
10058
+ // cancel auto-drop when call is
10059
+ createSubscription(this.state.session$, (session) => {
10060
+ if (!this.ringing)
10061
+ return;
10062
+ const receiverId = this.clientStore.connectedUser?.id;
10063
+ if (!receiverId)
10064
+ return;
10065
+ const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
10066
+ const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
10067
+ if (isAcceptedByMe || isRejectedByMe) {
10068
+ this.cancelAutoDrop();
10069
+ }
10070
+ }));
10071
+ this.leaveCallHooks.add(
10072
+ // "ringing" mode effects and event handlers
10073
+ createSubscription(this.ringingSubject, (isRinging) => {
10074
+ if (!isRinging)
10075
+ return;
10076
+ const callSession = this.state.session;
10077
+ const receiver_id = this.clientStore.connectedUser?.id;
10078
+ const ended_at = callSession?.ended_at;
10079
+ const created_by_id = this.state.createdBy?.id;
10080
+ const rejected_by = callSession?.rejected_by;
10081
+ const accepted_by = callSession?.accepted_by;
10082
+ let leaveCallIdle = false;
10083
+ if (ended_at) {
10084
+ // call was ended before it was accepted or rejected so we should leave it to idle
10085
+ leaveCallIdle = true;
10086
+ }
10087
+ else if (created_by_id && rejected_by) {
10088
+ if (rejected_by[created_by_id]) {
10089
+ // call was cancelled by the caller
10090
+ leaveCallIdle = true;
10091
+ }
10092
+ }
10093
+ else if (receiver_id && rejected_by) {
10094
+ if (rejected_by[receiver_id]) {
10095
+ // call was rejected by the receiver in some other device
10096
+ leaveCallIdle = true;
10097
+ }
10098
+ }
10099
+ else if (receiver_id && accepted_by) {
10100
+ if (accepted_by[receiver_id]) {
10101
+ // call was accepted by the receiver in some other device
10102
+ leaveCallIdle = true;
10103
+ }
10104
+ }
10105
+ if (leaveCallIdle) {
10106
+ if (this.state.callingState !== exports.CallingState.IDLE) {
10107
+ this.state.setCallingState(exports.CallingState.IDLE);
10108
+ }
10109
+ }
10110
+ else {
10111
+ if (this.state.callingState === exports.CallingState.IDLE) {
10112
+ this.state.setCallingState(exports.CallingState.RINGING);
10113
+ }
10114
+ this.scheduleAutoDrop();
10115
+ this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
10116
+ }
10117
+ }));
10118
+ };
10107
10119
  this.handleOwnCapabilitiesUpdated = async (ownCapabilities) => {
10108
10120
  // update the permission context.
10109
10121
  this.permissionsContext.setPermissions(ownCapabilities);
@@ -10216,14 +10228,15 @@ class Call {
10216
10228
  this.statsReporter = undefined;
10217
10229
  this.sfuStatsReporter?.stop();
10218
10230
  this.sfuStatsReporter = undefined;
10219
- this.subscriber?.close();
10231
+ this.subscriber?.dispose();
10220
10232
  this.subscriber = undefined;
10221
- this.publisher?.close({ stopTracks: true });
10233
+ this.publisher?.dispose();
10222
10234
  this.publisher = undefined;
10223
10235
  await this.sfuClient?.leaveAndClose(reason);
10224
10236
  this.sfuClient = undefined;
10225
10237
  this.dynascaleManager.setSfuClient(undefined);
10226
10238
  this.state.setCallingState(exports.CallingState.LEFT);
10239
+ this.state.dispose();
10227
10240
  // Call all leave call hooks, e.g. to clean up global event handlers
10228
10241
  this.leaveCallHooks.forEach((hook) => hook());
10229
10242
  this.initialized = false;
@@ -10255,7 +10268,8 @@ class Call {
10255
10268
  // call.ring event excludes the call creator in the members list
10256
10269
  // as the creator does not get the ring event
10257
10270
  // so update the member list accordingly
10258
- const creator = this.state.members.find((m) => m.user.id === event.call.created_by.id);
10271
+ const { created_by, settings } = event.call;
10272
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
10259
10273
  if (!creator) {
10260
10274
  this.state.setMembers(event.members);
10261
10275
  }
@@ -10270,7 +10284,7 @@ class Call {
10270
10284
  // const calls = useCalls().filter((c) => c.ringing);
10271
10285
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
10272
10286
  this.clientStore.setCalls([this, ...calls]);
10273
- await this.applyDeviceConfig(false);
10287
+ await this.applyDeviceConfig(settings, false);
10274
10288
  };
10275
10289
  /**
10276
10290
  * Loads the information about the call.
@@ -10293,7 +10307,7 @@ class Call {
10293
10307
  this.watching = true;
10294
10308
  this.clientStore.registerCall(this);
10295
10309
  }
10296
- await this.applyDeviceConfig(false);
10310
+ await this.applyDeviceConfig(response.call.settings, false);
10297
10311
  return response;
10298
10312
  };
10299
10313
  /**
@@ -10315,7 +10329,7 @@ class Call {
10315
10329
  this.watching = true;
10316
10330
  this.clientStore.registerCall(this);
10317
10331
  }
10318
- await this.applyDeviceConfig(false);
10332
+ await this.applyDeviceConfig(response.call.settings, false);
10319
10333
  return response;
10320
10334
  };
10321
10335
  /**
@@ -10417,19 +10431,32 @@ class Call {
10417
10431
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
10418
10432
  if (previousSfuClient !== sfuClient) {
10419
10433
  // prepare a generic SDP and send it to the SFU.
10420
- // this is a throw-away SDP that the SFU will use to determine
10434
+ // these are throw-away SDPs that the SFU will use to determine
10421
10435
  // the capabilities of the client (codec support, etc.)
10422
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
10423
- const reconnectDetails = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
10436
+ const [subscriberSdp, publisherSdp] = await Promise.all([
10437
+ getGenericSdp('recvonly'),
10438
+ getGenericSdp('sendonly'),
10439
+ ]);
10440
+ const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
10441
+ const reconnectDetails = isReconnecting
10424
10442
  ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
10425
10443
  : undefined;
10426
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
10427
- subscriberSdp: receivingCapabilitiesSdp,
10428
- publisherSdp: '',
10444
+ const preferredPublishOptions = !isReconnecting
10445
+ ? this.getPreferredPublishOptions()
10446
+ : this.currentPublishOptions || [];
10447
+ const preferredSubscribeOptions = !isReconnecting
10448
+ ? this.getPreferredSubscribeOptions()
10449
+ : [];
10450
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
10451
+ subscriberSdp,
10452
+ publisherSdp,
10429
10453
  clientDetails,
10430
10454
  fastReconnect: performingFastReconnect,
10431
10455
  reconnectDetails,
10456
+ preferredPublishOptions,
10457
+ preferredSubscribeOptions,
10432
10458
  });
10459
+ this.currentPublishOptions = publishOptions;
10433
10460
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
10434
10461
  if (callState) {
10435
10462
  this.state.updateFromSfuCallState(callState, sfuClient.sessionId, reconnectDetails);
@@ -10454,17 +10481,13 @@ class Call {
10454
10481
  connectionConfig,
10455
10482
  clientDetails,
10456
10483
  statsOptions,
10484
+ publishOptions: this.currentPublishOptions || [],
10457
10485
  closePreviousInstances: !performingMigration,
10458
10486
  });
10459
10487
  }
10460
10488
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
10461
10489
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
10462
- this.sfuStatsReporter?.sendTelemetryData({
10463
- data: {
10464
- oneofKind: 'connectionTimeSeconds',
10465
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
10466
- },
10467
- });
10490
+ this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
10468
10491
  }
10469
10492
  if (performingRejoin) {
10470
10493
  const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
@@ -10475,8 +10498,8 @@ class Call {
10475
10498
  }
10476
10499
  // device settings should be applied only once, we don't have to
10477
10500
  // re-apply them on later reconnections or server-side data fetches
10478
- if (!this.deviceSettingsAppliedOnce) {
10479
- await this.applyDeviceConfig(true);
10501
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
10502
+ await this.applyDeviceConfig(this.state.settings, true);
10480
10503
  this.deviceSettingsAppliedOnce = true;
10481
10504
  }
10482
10505
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -10485,6 +10508,8 @@ class Call {
10485
10508
  // we will spam the other participants with push notifications and `call.ring` events.
10486
10509
  delete this.joinCallData?.ring;
10487
10510
  delete this.joinCallData?.notify;
10511
+ // reset the reconnect strategy to unspecified after a successful reconnection
10512
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
10488
10513
  this.logger('info', `Joined call ${this.cid}`);
10489
10514
  };
10490
10515
  /**
@@ -10494,7 +10519,7 @@ class Call {
10494
10519
  this.getReconnectDetails = (migratingFromSfuId, previousSessionId) => {
10495
10520
  const strategy = this.reconnectStrategy;
10496
10521
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
10497
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
10522
+ const announcedTracks = this.publisher?.getAnnouncedTracksForReconnect() || [];
10498
10523
  return {
10499
10524
  strategy,
10500
10525
  announcedTracks,
@@ -10504,6 +10529,54 @@ class Call {
10504
10529
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
10505
10530
  };
10506
10531
  };
10532
+ /**
10533
+ * Prepares the preferred codec for the call.
10534
+ * This is an experimental client feature and subject to change.
10535
+ * @internal
10536
+ */
10537
+ this.getPreferredPublishOptions = () => {
10538
+ const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } = this.clientPublishOptions || {};
10539
+ if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers)
10540
+ return [];
10541
+ const codec = preferredCodec
10542
+ ? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
10543
+ : undefined;
10544
+ const preferredPublishOptions = [
10545
+ PublishOption.create({
10546
+ trackType: TrackType.VIDEO,
10547
+ codec,
10548
+ bitrate: preferredBitrate,
10549
+ maxSpatialLayers: maxSimulcastLayers,
10550
+ }),
10551
+ ];
10552
+ const screenShareSettings = this.screenShare.getSettings();
10553
+ if (screenShareSettings) {
10554
+ preferredPublishOptions.push(PublishOption.create({
10555
+ trackType: TrackType.SCREEN_SHARE,
10556
+ fps: screenShareSettings.maxFramerate,
10557
+ bitrate: screenShareSettings.maxBitrate,
10558
+ }));
10559
+ }
10560
+ return preferredPublishOptions;
10561
+ };
10562
+ /**
10563
+ * Prepares the preferred options for subscribing to tracks.
10564
+ * This is an experimental client feature and subject to change.
10565
+ * @internal
10566
+ */
10567
+ this.getPreferredSubscribeOptions = () => {
10568
+ const { subscriberCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
10569
+ if (!subscriberCodec || !subscriberFmtpLine)
10570
+ return [];
10571
+ return [
10572
+ SubscribeOption.create({
10573
+ trackType: TrackType.VIDEO,
10574
+ codecs: [
10575
+ { name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
10576
+ ],
10577
+ }),
10578
+ ];
10579
+ };
10507
10580
  /**
10508
10581
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
10509
10582
  * Uses the provided SFU client to restore the ICE connection.
@@ -10534,9 +10607,9 @@ class Call {
10534
10607
  * @internal
10535
10608
  */
10536
10609
  this.initPublisherAndSubscriber = (opts) => {
10537
- const { sfuClient, connectionConfig, clientDetails, statsOptions, closePreviousInstances, } = opts;
10610
+ const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, } = opts;
10538
10611
  if (closePreviousInstances && this.subscriber) {
10539
- this.subscriber.close();
10612
+ this.subscriber.dispose();
10540
10613
  }
10541
10614
  this.subscriber = new Subscriber({
10542
10615
  sfuClient,
@@ -10555,18 +10628,14 @@ class Call {
10555
10628
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
10556
10629
  if (!isAnonymous) {
10557
10630
  if (closePreviousInstances && this.publisher) {
10558
- this.publisher.close({ stopTracks: false });
10631
+ this.publisher.dispose();
10559
10632
  }
10560
- const audioSettings = this.state.settings?.audio;
10561
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
10562
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
10563
10633
  this.publisher = new Publisher({
10564
10634
  sfuClient,
10565
10635
  dispatcher: this.dispatcher,
10566
10636
  state: this.state,
10567
10637
  connectionConfig,
10568
- isDtxEnabled,
10569
- isRedEnabled,
10638
+ publishOptions,
10570
10639
  logTag: String(this.sfuClientTag),
10571
10640
  onUnrecoverableError: () => {
10572
10641
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -10713,47 +10782,31 @@ class Call {
10713
10782
  * @internal
10714
10783
  */
10715
10784
  this.reconnectFast = async () => {
10716
- let reconnectStartTime = Date.now();
10785
+ const reconnectStartTime = Date.now();
10717
10786
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
10718
10787
  this.state.setCallingState(exports.CallingState.RECONNECTING);
10719
10788
  await this.join(this.joinCallData);
10720
- this.sfuStatsReporter?.sendTelemetryData({
10721
- data: {
10722
- oneofKind: 'reconnection',
10723
- reconnection: {
10724
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10725
- strategy: WebsocketReconnectStrategy.FAST,
10726
- },
10727
- },
10728
- });
10789
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
10729
10790
  };
10730
10791
  /**
10731
10792
  * Initiates the reconnection flow with the "rejoin" strategy.
10732
10793
  * @internal
10733
10794
  */
10734
10795
  this.reconnectRejoin = async () => {
10735
- let reconnectStartTime = Date.now();
10796
+ const reconnectStartTime = Date.now();
10736
10797
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
10737
10798
  this.state.setCallingState(exports.CallingState.RECONNECTING);
10738
10799
  await this.join(this.joinCallData);
10739
10800
  await this.restorePublishedTracks();
10740
10801
  this.restoreSubscribedTracks();
10741
- this.sfuStatsReporter?.sendTelemetryData({
10742
- data: {
10743
- oneofKind: 'reconnection',
10744
- reconnection: {
10745
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10746
- strategy: WebsocketReconnectStrategy.REJOIN,
10747
- },
10748
- },
10749
- });
10802
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
10750
10803
  };
10751
10804
  /**
10752
10805
  * Initiates the reconnection flow with the "migrate" strategy.
10753
10806
  * @internal
10754
10807
  */
10755
10808
  this.reconnectMigrate = async () => {
10756
- let reconnectStartTime = Date.now();
10809
+ const reconnectStartTime = Date.now();
10757
10810
  const currentSfuClient = this.sfuClient;
10758
10811
  if (!currentSfuClient) {
10759
10812
  throw new Error('Cannot migrate without an active SFU client');
@@ -10787,20 +10840,12 @@ class Call {
10787
10840
  this.state.setCallingState(exports.CallingState.JOINED);
10788
10841
  }
10789
10842
  finally {
10790
- currentSubscriber?.close();
10791
- currentPublisher?.close({ stopTracks: false });
10843
+ currentSubscriber?.dispose();
10844
+ currentPublisher?.dispose();
10792
10845
  // and close the previous SFU client, without specifying close code
10793
10846
  currentSfuClient.close();
10794
10847
  }
10795
- this.sfuStatsReporter?.sendTelemetryData({
10796
- data: {
10797
- oneofKind: 'reconnection',
10798
- reconnection: {
10799
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10800
- strategy: WebsocketReconnectStrategy.MIGRATE,
10801
- },
10802
- },
10803
- });
10848
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.MIGRATE, (Date.now() - reconnectStartTime) / 1000);
10804
10849
  };
10805
10850
  /**
10806
10851
  * Registers the various event handlers for reconnection.
@@ -10877,23 +10922,16 @@ class Call {
10877
10922
  // the tracks need to be restored in their original order of publishing
10878
10923
  // otherwise, we might get `m-lines order mismatch` errors
10879
10924
  for (const trackType of this.trackPublishOrder) {
10925
+ let mediaStream;
10880
10926
  switch (trackType) {
10881
10927
  case TrackType.AUDIO:
10882
- const audioStream = this.microphone.state.mediaStream;
10883
- if (audioStream) {
10884
- await this.publishAudioStream(audioStream);
10885
- }
10928
+ mediaStream = this.microphone.state.mediaStream;
10886
10929
  break;
10887
10930
  case TrackType.VIDEO:
10888
- const videoStream = this.camera.state.mediaStream;
10889
- if (videoStream)
10890
- await this.publishVideoStream(videoStream);
10931
+ mediaStream = this.camera.state.mediaStream;
10891
10932
  break;
10892
10933
  case TrackType.SCREEN_SHARE:
10893
- const screenShareStream = this.screenShare.state.mediaStream;
10894
- if (screenShareStream) {
10895
- await this.publishScreenShareStream(screenShareStream);
10896
- }
10934
+ mediaStream = this.screenShare.state.mediaStream;
10897
10935
  break;
10898
10936
  // screen share audio can't exist without a screen share, so we handle it there
10899
10937
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -10903,6 +10941,8 @@ class Call {
10903
10941
  ensureExhausted(trackType, 'Unknown track type');
10904
10942
  break;
10905
10943
  }
10944
+ if (mediaStream)
10945
+ await this.publish(mediaStream, trackType);
10906
10946
  }
10907
10947
  };
10908
10948
  /**
@@ -10917,105 +10957,111 @@ class Call {
10917
10957
  };
10918
10958
  /**
10919
10959
  * Starts publishing the given video stream to the call.
10920
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
10921
- *
10922
- * Consecutive calls to this method will replace the previously published stream.
10923
- * The previous video stream will be stopped.
10924
- *
10925
- * @param videoStream the video stream to publish.
10960
+ * @deprecated use `call.publish()`.
10926
10961
  */
10927
10962
  this.publishVideoStream = async (videoStream) => {
10928
- if (!this.sfuClient)
10929
- throw new Error(`Call not joined yet.`);
10930
- // joining is in progress, and we should wait until the client is ready
10931
- await this.sfuClient.joinTask;
10932
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
10933
- throw new Error('No permission to publish video');
10934
- }
10935
- if (!this.publisher)
10936
- throw new Error('Publisher is not initialized');
10937
- const [videoTrack] = videoStream.getVideoTracks();
10938
- if (!videoTrack)
10939
- throw new Error('There is no video track in the stream');
10940
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
10941
- this.trackPublishOrder.push(TrackType.VIDEO);
10942
- }
10943
- await this.publisher.publishStream(videoStream, videoTrack, TrackType.VIDEO, this.publishOptions);
10963
+ await this.publish(videoStream, TrackType.VIDEO);
10944
10964
  };
10945
10965
  /**
10946
10966
  * Starts publishing the given audio stream to the call.
10947
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
10948
- *
10949
- * Consecutive calls to this method will replace the audio stream that is currently being published.
10950
- * The previous audio stream will be stopped.
10951
- *
10952
- * @param audioStream the audio stream to publish.
10967
+ * @deprecated use `call.publish()`
10953
10968
  */
10954
10969
  this.publishAudioStream = async (audioStream) => {
10955
- if (!this.sfuClient)
10956
- throw new Error(`Call not joined yet.`);
10957
- // joining is in progress, and we should wait until the client is ready
10958
- await this.sfuClient.joinTask;
10959
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
10960
- throw new Error('No permission to publish audio');
10961
- }
10962
- if (!this.publisher)
10963
- throw new Error('Publisher is not initialized');
10964
- const [audioTrack] = audioStream.getAudioTracks();
10965
- if (!audioTrack)
10966
- throw new Error('There is no audio track in the stream');
10967
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
10968
- this.trackPublishOrder.push(TrackType.AUDIO);
10969
- }
10970
- await this.publisher.publishStream(audioStream, audioTrack, TrackType.AUDIO);
10970
+ await this.publish(audioStream, TrackType.AUDIO);
10971
10971
  };
10972
10972
  /**
10973
10973
  * Starts publishing the given screen-share stream to the call.
10974
- *
10975
- * Consecutive calls to this method will replace the previous screen-share stream.
10976
- * The previous screen-share stream will be stopped.
10977
- *
10978
- * @param screenShareStream the screen-share stream to publish.
10974
+ * @deprecated use `call.publish()`
10979
10975
  */
10980
10976
  this.publishScreenShareStream = async (screenShareStream) => {
10977
+ await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
10978
+ };
10979
+ /**
10980
+ * Publishes the given media stream.
10981
+ *
10982
+ * @param mediaStream the media stream to publish.
10983
+ * @param trackType the type of the track to announce.
10984
+ */
10985
+ this.publish = async (mediaStream, trackType) => {
10981
10986
  if (!this.sfuClient)
10982
10987
  throw new Error(`Call not joined yet.`);
10983
10988
  // joining is in progress, and we should wait until the client is ready
10984
10989
  await this.sfuClient.joinTask;
10985
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
10986
- throw new Error('No permission to publish screen share');
10990
+ if (!this.permissionsContext.canPublish(trackType)) {
10991
+ throw new Error(`No permission to publish ${TrackType[trackType]}`);
10987
10992
  }
10988
10993
  if (!this.publisher)
10989
10994
  throw new Error('Publisher is not initialized');
10990
- const [screenShareTrack] = screenShareStream.getVideoTracks();
10991
- if (!screenShareTrack) {
10992
- throw new Error('There is no screen share track in the stream');
10995
+ const [track] = isAudioTrackType(trackType)
10996
+ ? mediaStream.getAudioTracks()
10997
+ : mediaStream.getVideoTracks();
10998
+ if (!track) {
10999
+ throw new Error(`There is no ${TrackType[trackType]} track in the stream`);
10993
11000
  }
10994
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
10995
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
10996
- }
10997
- const opts = {
10998
- screenShareSettings: this.screenShare.getSettings(),
10999
- };
11000
- await this.publisher.publishStream(screenShareStream, screenShareTrack, TrackType.SCREEN_SHARE, opts);
11001
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
11002
- if (screenShareAudioTrack) {
11003
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
11004
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
11001
+ if (track.readyState === 'ended') {
11002
+ throw new Error(`Can't publish ended tracks.`);
11003
+ }
11004
+ pushToIfMissing(this.trackPublishOrder, trackType);
11005
+ await this.publisher.publish(track, trackType);
11006
+ const trackTypes = [trackType];
11007
+ if (trackType === TrackType.SCREEN_SHARE) {
11008
+ const [audioTrack] = mediaStream.getAudioTracks();
11009
+ if (audioTrack) {
11010
+ pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
11011
+ await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
11012
+ trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
11005
11013
  }
11006
- await this.publisher.publishStream(screenShareStream, screenShareAudioTrack, TrackType.SCREEN_SHARE_AUDIO, opts);
11007
11014
  }
11015
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
11008
11016
  };
11009
11017
  /**
11010
11018
  * Stops publishing the given track type to the call, if it is currently being published.
11011
- * Underlying track will be stopped and removed from the publisher.
11012
11019
  *
11013
- * @param trackType the track type to stop publishing.
11014
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
11020
+ * @param trackTypes the track types to stop publishing.
11021
+ */
11022
+ this.stopPublish = async (...trackTypes) => {
11023
+ if (!this.sfuClient || !this.publisher)
11024
+ return;
11025
+ this.publisher.stopTracks(...trackTypes);
11026
+ await this.updateLocalStreamState(undefined, ...trackTypes);
11027
+ };
11028
+ /**
11029
+ * Updates the call state with the new stream.
11030
+ *
11031
+ * @param mediaStream the new stream to update the call state with.
11032
+ * If undefined, the stream will be removed from the call state.
11033
+ * @param trackTypes the track types to update the call state with.
11034
+ */
11035
+ this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
11036
+ if (!this.sfuClient || !this.sfuClient.sessionId)
11037
+ return;
11038
+ await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
11039
+ const { sessionId } = this.sfuClient;
11040
+ for (const trackType of trackTypes) {
11041
+ const streamStateProp = trackTypeToParticipantStreamKey(trackType);
11042
+ if (!streamStateProp)
11043
+ continue;
11044
+ this.state.updateParticipant(sessionId, (p) => ({
11045
+ publishedTracks: mediaStream
11046
+ ? pushToIfMissing([...p.publishedTracks], trackType)
11047
+ : p.publishedTracks.filter((t) => t !== trackType),
11048
+ [streamStateProp]: mediaStream,
11049
+ }));
11050
+ }
11051
+ };
11052
+ /**
11053
+ * Updates the preferred publishing options
11054
+ *
11055
+ * @internal
11056
+ * @param options the options to use.
11015
11057
  */
11016
- this.stopPublish = async (trackType, stopTrack = true) => {
11017
- this.logger('info', `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`);
11018
- await this.publisher?.unpublishStream(trackType, stopTrack);
11058
+ this.updatePublishOptions = (options) => {
11059
+ this.logger('warn', '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
11060
+ 'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.');
11061
+ if (this.state.callingState === exports.CallingState.JOINED) {
11062
+ this.logger('warn', 'Updating publish options after joining the call does not have an effect');
11063
+ }
11064
+ this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
11019
11065
  };
11020
11066
  /**
11021
11067
  * Notifies the SFU that a noise cancellation process has started.
@@ -11037,6 +11083,15 @@ class Call {
11037
11083
  this.logger('warn', 'Failed to notify stop of noise cancellation', err);
11038
11084
  });
11039
11085
  };
11086
+ /**
11087
+ * Notifies the SFU about the mute state of the given track types.
11088
+ * @internal
11089
+ */
11090
+ this.notifyTrackMuteState = async (muted, ...trackTypes) => {
11091
+ if (!this.sfuClient)
11092
+ return;
11093
+ await this.sfuClient.updateMuteStates(trackTypes.map((trackType) => ({ trackType, muted })));
11094
+ };
11040
11095
  /**
11041
11096
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
11042
11097
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -11181,7 +11236,43 @@ class Call {
11181
11236
  return this.streamClient.post(`${this.streamClientBasePath}/stop_transcription`);
11182
11237
  };
11183
11238
  /**
11184
- * Sends a `call.permission_request` event to all users connected to the call. The call settings object contains infomration about which permissions can be requested during a call (for example a user might be allowed to request permission to publish audio, but not video).
11239
+ * Starts the closed captions of the call.
11240
+ */
11241
+ this.startClosedCaptions = async (options) => {
11242
+ const trx = this.state.setCaptioning(true); // optimistic update
11243
+ try {
11244
+ return await this.streamClient.post(`${this.streamClientBasePath}/start_closed_captions`, options);
11245
+ }
11246
+ catch (err) {
11247
+ trx.rollback(); // revert the optimistic update
11248
+ throw err;
11249
+ }
11250
+ };
11251
+ /**
11252
+ * Stops the closed captions of the call.
11253
+ */
11254
+ this.stopClosedCaptions = async (options) => {
11255
+ const trx = this.state.setCaptioning(false); // optimistic update
11256
+ try {
11257
+ return await this.streamClient.post(`${this.streamClientBasePath}/stop_closed_captions`, options);
11258
+ }
11259
+ catch (err) {
11260
+ trx.rollback(); // revert the optimistic update
11261
+ throw err;
11262
+ }
11263
+ };
11264
+ /**
11265
+ * Updates the closed caption settings.
11266
+ *
11267
+ * @param config the closed caption settings to apply
11268
+ */
11269
+ this.updateClosedCaptionSettings = (config) => {
11270
+ this.state.updateClosedCaptionSettings(config);
11271
+ };
11272
+ /**
11273
+ * Sends a `call.permission_request` event to all users connected to the call.
11274
+ * The call settings object contains information about which permissions can be requested during a call
11275
+ * (for example, a user might be allowed to request permission to publish audio, but not video).
11185
11276
  */
11186
11277
  this.requestPermissions = async (data) => {
11187
11278
  const { permissions } = data;
@@ -11464,70 +11555,14 @@ class Call {
11464
11555
  *
11465
11556
  * @internal
11466
11557
  */
11467
- this.applyDeviceConfig = async (status) => {
11468
- await this.initCamera({ setStatus: status }).catch((err) => {
11558
+ this.applyDeviceConfig = async (settings, publish) => {
11559
+ await this.camera.apply(settings.video, publish).catch((err) => {
11469
11560
  this.logger('warn', 'Camera init failed', err);
11470
11561
  });
11471
- await this.initMic({ setStatus: status }).catch((err) => {
11562
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
11472
11563
  this.logger('warn', 'Mic init failed', err);
11473
11564
  });
11474
11565
  };
11475
- this.initCamera = async (options) => {
11476
- // Wait for any in progress camera operation
11477
- await this.camera.statusChangeSettled();
11478
- if (this.state.localParticipant?.videoStream ||
11479
- !this.permissionsContext.hasPermission('send-video')) {
11480
- return;
11481
- }
11482
- // Set camera direction if it's not yet set
11483
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
11484
- let defaultDirection = 'front';
11485
- const backendSetting = this.state.settings?.video.camera_facing;
11486
- if (backendSetting) {
11487
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
11488
- }
11489
- this.camera.state.setDirection(defaultDirection);
11490
- }
11491
- // Set target resolution
11492
- const targetResolution = this.state.settings?.video.target_resolution;
11493
- if (targetResolution) {
11494
- await this.camera.selectTargetResolution(targetResolution);
11495
- }
11496
- if (options.setStatus) {
11497
- // Publish already that was set before we joined
11498
- if (this.camera.enabled &&
11499
- this.camera.state.mediaStream &&
11500
- !this.publisher?.isPublishing(TrackType.VIDEO)) {
11501
- await this.publishVideoStream(this.camera.state.mediaStream);
11502
- }
11503
- // Start camera if backend config specifies, and there is no local setting
11504
- if (this.camera.state.status === undefined &&
11505
- this.state.settings?.video.camera_default_on) {
11506
- await this.camera.enable();
11507
- }
11508
- }
11509
- };
11510
- this.initMic = async (options) => {
11511
- // Wait for any in progress mic operation
11512
- await this.microphone.statusChangeSettled();
11513
- if (this.state.localParticipant?.audioStream ||
11514
- !this.permissionsContext.hasPermission('send-audio')) {
11515
- return;
11516
- }
11517
- if (options.setStatus) {
11518
- // Publish media stream that was set before we joined
11519
- if (this.microphone.enabled &&
11520
- this.microphone.state.mediaStream &&
11521
- !this.publisher?.isPublishing(TrackType.AUDIO)) {
11522
- await this.publishAudioStream(this.microphone.state.mediaStream);
11523
- }
11524
- // Start mic if backend config specifies, and there is no local setting
11525
- if (this.microphone.state.status === undefined &&
11526
- this.state.settings?.audio.mic_default_on) {
11527
- await this.microphone.enable();
11528
- }
11529
- }
11530
- };
11531
11566
  /**
11532
11567
  * Will begin tracking the given element for visibility changes within the
11533
11568
  * configured viewport element (`call.setViewport`).
@@ -11676,109 +11711,6 @@ class Call {
11676
11711
  this.screenShare = new ScreenShareManager(this);
11677
11712
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
11678
11713
  }
11679
- async setup() {
11680
- await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11681
- if (this.initialized)
11682
- return;
11683
- this.leaveCallHooks.add(this.on('all', (event) => {
11684
- // update state with the latest event data
11685
- this.state.updateFromEvent(event);
11686
- }));
11687
- this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
11688
- this.registerEffects();
11689
- this.registerReconnectHandlers();
11690
- if (this.state.callingState === exports.CallingState.LEFT) {
11691
- this.state.setCallingState(exports.CallingState.IDLE);
11692
- }
11693
- this.initialized = true;
11694
- });
11695
- }
11696
- registerEffects() {
11697
- this.leaveCallHooks.add(
11698
- // handles updating the permissions context when the settings change.
11699
- createSubscription(this.state.settings$, (settings) => {
11700
- if (!settings)
11701
- return;
11702
- this.permissionsContext.setCallSettings(settings);
11703
- }));
11704
- this.leaveCallHooks.add(
11705
- // handle the case when the user permissions are modified.
11706
- createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
11707
- this.leaveCallHooks.add(
11708
- // handles the case when the user is blocked by the call owner.
11709
- createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
11710
- if (!blockedUserIds || blockedUserIds.length === 0)
11711
- return;
11712
- const currentUserId = this.currentUserId;
11713
- if (currentUserId && blockedUserIds.includes(currentUserId)) {
11714
- this.logger('info', 'Leaving call because of being blocked');
11715
- await this.leave({ reason: 'user blocked' }).catch((err) => {
11716
- this.logger('error', 'Error leaving call after being blocked', err);
11717
- });
11718
- }
11719
- }));
11720
- this.leaveCallHooks.add(
11721
- // cancel auto-drop when call is
11722
- createSubscription(this.state.session$, (session) => {
11723
- if (!this.ringing)
11724
- return;
11725
- const receiverId = this.clientStore.connectedUser?.id;
11726
- if (!receiverId)
11727
- return;
11728
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
11729
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
11730
- if (isAcceptedByMe || isRejectedByMe) {
11731
- this.cancelAutoDrop();
11732
- }
11733
- }));
11734
- this.leaveCallHooks.add(
11735
- // "ringing" mode effects and event handlers
11736
- createSubscription(this.ringingSubject, (isRinging) => {
11737
- if (!isRinging)
11738
- return;
11739
- const callSession = this.state.session;
11740
- const receiver_id = this.clientStore.connectedUser?.id;
11741
- const ended_at = callSession?.ended_at;
11742
- const created_by_id = this.state.createdBy?.id;
11743
- const rejected_by = callSession?.rejected_by;
11744
- const accepted_by = callSession?.accepted_by;
11745
- let leaveCallIdle = false;
11746
- if (ended_at) {
11747
- // call was ended before it was accepted or rejected so we should leave it to idle
11748
- leaveCallIdle = true;
11749
- }
11750
- else if (created_by_id && rejected_by) {
11751
- if (rejected_by[created_by_id]) {
11752
- // call was cancelled by the caller
11753
- leaveCallIdle = true;
11754
- }
11755
- }
11756
- else if (receiver_id && rejected_by) {
11757
- if (rejected_by[receiver_id]) {
11758
- // call was rejected by the receiver in some other device
11759
- leaveCallIdle = true;
11760
- }
11761
- }
11762
- else if (receiver_id && accepted_by) {
11763
- if (accepted_by[receiver_id]) {
11764
- // call was accepted by the receiver in some other device
11765
- leaveCallIdle = true;
11766
- }
11767
- }
11768
- if (leaveCallIdle) {
11769
- if (this.state.callingState !== exports.CallingState.IDLE) {
11770
- this.state.setCallingState(exports.CallingState.IDLE);
11771
- }
11772
- }
11773
- else {
11774
- if (this.state.callingState === exports.CallingState.IDLE) {
11775
- this.state.setCallingState(exports.CallingState.RINGING);
11776
- }
11777
- this.scheduleAutoDrop();
11778
- this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
11779
- }
11780
- }));
11781
- }
11782
11714
  /**
11783
11715
  * A flag indicating whether the call is "ringing" type of call.
11784
11716
  */
@@ -11797,15 +11729,6 @@ class Call {
11797
11729
  get isCreatedByMe() {
11798
11730
  return this.state.createdBy?.id === this.currentUserId;
11799
11731
  }
11800
- /**
11801
- * Updates the preferred publishing options
11802
- *
11803
- * @internal
11804
- * @param options the options to use.
11805
- */
11806
- updatePublishOptions(options) {
11807
- this.publishOptions = { ...this.publishOptions, ...options };
11808
- }
11809
11732
  }
11810
11733
 
11811
11734
  /**
@@ -12913,7 +12836,7 @@ class StreamClient {
12913
12836
  return await this.wsConnection.connect(this.defaultWSTimeout);
12914
12837
  };
12915
12838
  this.getUserAgent = () => {
12916
- const version = "1.13.1";
12839
+ const version = "1.15.0";
12917
12840
  return (this.userAgent ||
12918
12841
  `stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
12919
12842
  };
@@ -13213,7 +13136,7 @@ class StreamVideoClient {
13213
13136
  clientStore: this.writeableStateStore,
13214
13137
  });
13215
13138
  call.state.updateFromCallResponse(c.call);
13216
- await call.applyDeviceConfig(false);
13139
+ await call.applyDeviceConfig(c.call.settings, false);
13217
13140
  if (data.watch) {
13218
13141
  this.writeableStateStore.registerCall(call);
13219
13142
  }
@@ -13439,6 +13362,7 @@ exports.CameraManagerState = CameraManagerState;
13439
13362
  exports.ChannelConfigWithInfoAutomodBehaviorEnum = ChannelConfigWithInfoAutomodBehaviorEnum;
13440
13363
  exports.ChannelConfigWithInfoAutomodEnum = ChannelConfigWithInfoAutomodEnum;
13441
13364
  exports.ChannelConfigWithInfoBlocklistBehaviorEnum = ChannelConfigWithInfoBlocklistBehaviorEnum;
13365
+ exports.ChannelOwnCapability = ChannelOwnCapability;
13442
13366
  exports.CreateDeviceRequestPushProviderEnum = CreateDeviceRequestPushProviderEnum;
13443
13367
  exports.DynascaleManager = DynascaleManager;
13444
13368
  exports.ErrorFromResponse = ErrorFromResponse;
@@ -13461,7 +13385,9 @@ exports.StreamSfuClient = StreamSfuClient;
13461
13385
  exports.StreamVideoClient = StreamVideoClient;
13462
13386
  exports.StreamVideoReadOnlyStateStore = StreamVideoReadOnlyStateStore;
13463
13387
  exports.StreamVideoWriteableStateStore = StreamVideoWriteableStateStore;
13388
+ exports.TranscriptionSettingsRequestClosedCaptionModeEnum = TranscriptionSettingsRequestClosedCaptionModeEnum;
13464
13389
  exports.TranscriptionSettingsRequestModeEnum = TranscriptionSettingsRequestModeEnum;
13390
+ exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettingsResponseClosedCaptionModeEnum;
13465
13391
  exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
13466
13392
  exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;
13467
13393
  exports.VideoSettingsResponseCameraFacingEnum = VideoSettingsResponseCameraFacingEnum;