@stream-io/video-client 1.24.0 → 1.25.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 (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +335 -127
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +334 -126
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +335 -127
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamSfuClient.d.ts +12 -4
  9. package/dist/src/StreamVideoClient.d.ts +3 -1
  10. package/dist/src/coordinator/connection/errors.d.ts +1 -0
  11. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +23 -4
  13. package/dist/src/rtc/NegotiationError.d.ts +15 -0
  14. package/dist/src/rtc/Publisher.d.ts +2 -2
  15. package/dist/src/rtc/helpers/sdp.d.ts +7 -0
  16. package/dist/src/types.d.ts +11 -0
  17. package/package.json +1 -1
  18. package/src/Call.ts +66 -38
  19. package/src/StreamSfuClient.ts +17 -7
  20. package/src/StreamVideoClient.ts +17 -7
  21. package/src/coordinator/connection/connection.ts +2 -1
  22. package/src/coordinator/connection/errors.ts +31 -0
  23. package/src/devices/ScreenShareManager.ts +12 -2
  24. package/src/devices/devices.ts +23 -12
  25. package/src/events/__tests__/internal.test.ts +1 -0
  26. package/src/gen/google/protobuf/struct.ts +2 -2
  27. package/src/gen/google/protobuf/timestamp.ts +1 -1
  28. package/src/gen/video/sfu/event/events.ts +15 -15
  29. package/src/gen/video/sfu/models/models.ts +9 -5
  30. package/src/gen/video/sfu/signal_rpc/signal.client.ts +1 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.ts +6 -6
  32. package/src/rtc/BasePeerConnection.ts +132 -46
  33. package/src/rtc/NegotiationError.ts +21 -0
  34. package/src/rtc/Publisher.ts +12 -9
  35. package/src/rtc/Subscriber.ts +8 -2
  36. package/src/rtc/__tests__/Publisher.test.ts +160 -17
  37. package/src/rtc/__tests__/Subscriber.test.ts +31 -14
  38. package/src/rtc/helpers/__tests__/sdp.stereo.test.ts +120 -0
  39. package/src/rtc/helpers/sdp.ts +43 -1
  40. package/src/types.ts +12 -0
package/dist/index.es.js CHANGED
@@ -6,7 +6,7 @@ export { AxiosError } from 'axios';
6
6
  import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transport';
7
7
  import { ReplaySubject, combineLatest, BehaviorSubject, shareReplay, map, distinctUntilChanged, takeWhile, distinctUntilKeyChanged, fromEventPattern, startWith, concatMap, merge, from, fromEvent, debounceTime, pairwise, of } from 'rxjs';
8
8
  import { UAParser } from 'ua-parser-js';
9
- import { parse } from 'sdp-transform';
9
+ import { parse, write } from 'sdp-transform';
10
10
  import https from 'https';
11
11
 
12
12
  /* tslint:disable */
@@ -421,7 +421,7 @@ class ErrorFromResponse extends Error {
421
421
  }
422
422
  }
423
423
 
424
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
424
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
425
425
  // @generated from protobuf file "google/protobuf/struct.proto" (package "google.protobuf", syntax proto3)
426
426
  // tslint:disable
427
427
  //
@@ -654,7 +654,7 @@ class ListValue$Type extends MessageType {
654
654
  no: 1,
655
655
  name: 'values',
656
656
  kind: 'message',
657
- repeat: 1 /*RepeatType.PACKED*/,
657
+ repeat: 2 /*RepeatType.UNPACKED*/,
658
658
  T: () => Value,
659
659
  },
660
660
  ]);
@@ -686,7 +686,7 @@ class ListValue$Type extends MessageType {
686
686
  */
687
687
  const ListValue = new ListValue$Type();
688
688
 
689
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
689
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
690
690
  // @generated from protobuf file "google/protobuf/timestamp.proto" (package "google.protobuf", syntax proto3)
691
691
  // tslint:disable
692
692
  //
@@ -822,7 +822,7 @@ class Timestamp$Type extends MessageType {
822
822
  */
823
823
  const Timestamp = new Timestamp$Type();
824
824
 
825
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
825
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
826
826
  // @generated from protobuf file "video/sfu/models/models.proto" (package "stream.video.sfu.models", syntax proto3)
827
827
  // tslint:disable
828
828
  /**
@@ -964,6 +964,10 @@ var ErrorCode;
964
964
  * @generated from protobuf enum value: ERROR_CODE_PARTICIPANT_MEDIA_TRANSPORT_FAILURE = 205;
965
965
  */
966
966
  ErrorCode[ErrorCode["PARTICIPANT_MEDIA_TRANSPORT_FAILURE"] = 205] = "PARTICIPANT_MEDIA_TRANSPORT_FAILURE";
967
+ /**
968
+ * @generated from protobuf enum value: ERROR_CODE_PARTICIPANT_SIGNAL_LOST = 206;
969
+ */
970
+ ErrorCode[ErrorCode["PARTICIPANT_SIGNAL_LOST"] = 206] = "PARTICIPANT_SIGNAL_LOST";
967
971
  /**
968
972
  * @generated from protobuf enum value: ERROR_CODE_CALL_NOT_FOUND = 300;
969
973
  */
@@ -1244,7 +1248,7 @@ class CallState$Type extends MessageType {
1244
1248
  no: 1,
1245
1249
  name: 'participants',
1246
1250
  kind: 'message',
1247
- repeat: 1 /*RepeatType.PACKED*/,
1251
+ repeat: 2 /*RepeatType.UNPACKED*/,
1248
1252
  T: () => Participant,
1249
1253
  },
1250
1254
  { no: 2, name: 'started_at', kind: 'message', T: () => Timestamp },
@@ -1258,7 +1262,7 @@ class CallState$Type extends MessageType {
1258
1262
  no: 4,
1259
1263
  name: 'pins',
1260
1264
  kind: 'message',
1261
- repeat: 1 /*RepeatType.PACKED*/,
1265
+ repeat: 2 /*RepeatType.UNPACKED*/,
1262
1266
  T: () => Pin,
1263
1267
  },
1264
1268
  ]);
@@ -1436,7 +1440,7 @@ class SubscribeOption$Type extends MessageType {
1436
1440
  no: 2,
1437
1441
  name: 'codecs',
1438
1442
  kind: 'message',
1439
- repeat: 1 /*RepeatType.PACKED*/,
1443
+ repeat: 2 /*RepeatType.UNPACKED*/,
1440
1444
  T: () => Codec,
1441
1445
  },
1442
1446
  ]);
@@ -1569,7 +1573,7 @@ class TrackInfo$Type extends MessageType {
1569
1573
  no: 5,
1570
1574
  name: 'layers',
1571
1575
  kind: 'message',
1572
- repeat: 1 /*RepeatType.PACKED*/,
1576
+ repeat: 2 /*RepeatType.UNPACKED*/,
1573
1577
  T: () => VideoLayer,
1574
1578
  },
1575
1579
  { no: 6, name: 'mid', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
@@ -1936,7 +1940,7 @@ var models = /*#__PURE__*/Object.freeze({
1936
1940
  get WebsocketReconnectStrategy () { return WebsocketReconnectStrategy; }
1937
1941
  });
1938
1942
 
1939
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1943
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1940
1944
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
1941
1945
  // tslint:disable
1942
1946
  // @generated message type with reflection information, may provide speed optimized methods
@@ -2104,14 +2108,14 @@ class SendStatsRequest$Type extends MessageType {
2104
2108
  no: 16,
2105
2109
  name: 'encode_stats',
2106
2110
  kind: 'message',
2107
- repeat: 1 /*RepeatType.PACKED*/,
2111
+ repeat: 2 /*RepeatType.UNPACKED*/,
2108
2112
  T: () => PerformanceStats,
2109
2113
  },
2110
2114
  {
2111
2115
  no: 17,
2112
2116
  name: 'decode_stats',
2113
2117
  kind: 'message',
2114
- repeat: 1 /*RepeatType.PACKED*/,
2118
+ repeat: 2 /*RepeatType.UNPACKED*/,
2115
2119
  T: () => PerformanceStats,
2116
2120
  },
2117
2121
  {
@@ -2178,7 +2182,7 @@ class UpdateMuteStatesRequest$Type extends MessageType {
2178
2182
  no: 3,
2179
2183
  name: 'mute_states',
2180
2184
  kind: 'message',
2181
- repeat: 1 /*RepeatType.PACKED*/,
2185
+ repeat: 2 /*RepeatType.UNPACKED*/,
2182
2186
  T: () => TrackMuteState,
2183
2187
  },
2184
2188
  ]);
@@ -2255,7 +2259,7 @@ class UpdateSubscriptionsRequest$Type extends MessageType {
2255
2259
  no: 3,
2256
2260
  name: 'tracks',
2257
2261
  kind: 'message',
2258
- repeat: 1 /*RepeatType.PACKED*/,
2262
+ repeat: 2 /*RepeatType.UNPACKED*/,
2259
2263
  T: () => TrackSubscriptionDetails,
2260
2264
  },
2261
2265
  ]);
@@ -2354,7 +2358,7 @@ class SetPublisherRequest$Type extends MessageType {
2354
2358
  no: 3,
2355
2359
  name: 'tracks',
2356
2360
  kind: 'message',
2357
- repeat: 1 /*RepeatType.PACKED*/,
2361
+ repeat: 2 /*RepeatType.UNPACKED*/,
2358
2362
  T: () => TrackInfo,
2359
2363
  },
2360
2364
  ]);
@@ -2434,7 +2438,7 @@ const SignalServer = new ServiceType('stream.video.sfu.signal.SignalServer', [
2434
2438
  },
2435
2439
  ]);
2436
2440
 
2437
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2441
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2438
2442
  // @generated from protobuf file "video/sfu/event/events.proto" (package "stream.video.sfu.event", syntax proto3)
2439
2443
  // tslint:disable
2440
2444
  // @generated message type with reflection information, may provide speed optimized methods
@@ -2610,7 +2614,7 @@ class ChangePublishOptions$Type extends MessageType {
2610
2614
  no: 1,
2611
2615
  name: 'publish_options',
2612
2616
  kind: 'message',
2613
- repeat: 1 /*RepeatType.PACKED*/,
2617
+ repeat: 2 /*RepeatType.UNPACKED*/,
2614
2618
  T: () => PublishOption,
2615
2619
  },
2616
2620
  { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
@@ -2649,7 +2653,7 @@ class PinsChanged$Type extends MessageType {
2649
2653
  no: 1,
2650
2654
  name: 'pins',
2651
2655
  kind: 'message',
2652
- repeat: 1 /*RepeatType.PACKED*/,
2656
+ repeat: 2 /*RepeatType.UNPACKED*/,
2653
2657
  T: () => Pin,
2654
2658
  },
2655
2659
  ]);
@@ -2892,14 +2896,14 @@ class JoinRequest$Type extends MessageType {
2892
2896
  no: 9,
2893
2897
  name: 'preferred_publish_options',
2894
2898
  kind: 'message',
2895
- repeat: 1 /*RepeatType.PACKED*/,
2899
+ repeat: 2 /*RepeatType.UNPACKED*/,
2896
2900
  T: () => PublishOption,
2897
2901
  },
2898
2902
  {
2899
2903
  no: 10,
2900
2904
  name: 'preferred_subscribe_options',
2901
2905
  kind: 'message',
2902
- repeat: 1 /*RepeatType.PACKED*/,
2906
+ repeat: 2 /*RepeatType.UNPACKED*/,
2903
2907
  T: () => SubscribeOption,
2904
2908
  },
2905
2909
  ]);
@@ -2927,14 +2931,14 @@ class ReconnectDetails$Type extends MessageType {
2927
2931
  no: 3,
2928
2932
  name: 'announced_tracks',
2929
2933
  kind: 'message',
2930
- repeat: 1 /*RepeatType.PACKED*/,
2934
+ repeat: 2 /*RepeatType.UNPACKED*/,
2931
2935
  T: () => TrackInfo,
2932
2936
  },
2933
2937
  {
2934
2938
  no: 4,
2935
2939
  name: 'subscriptions',
2936
2940
  kind: 'message',
2937
- repeat: 1 /*RepeatType.PACKED*/,
2941
+ repeat: 2 /*RepeatType.UNPACKED*/,
2938
2942
  T: () => TrackSubscriptionDetails,
2939
2943
  },
2940
2944
  {
@@ -2977,14 +2981,14 @@ class Migration$Type extends MessageType {
2977
2981
  no: 2,
2978
2982
  name: 'announced_tracks',
2979
2983
  kind: 'message',
2980
- repeat: 1 /*RepeatType.PACKED*/,
2984
+ repeat: 2 /*RepeatType.UNPACKED*/,
2981
2985
  T: () => TrackInfo,
2982
2986
  },
2983
2987
  {
2984
2988
  no: 3,
2985
2989
  name: 'subscriptions',
2986
2990
  kind: 'message',
2987
- repeat: 1 /*RepeatType.PACKED*/,
2991
+ repeat: 2 /*RepeatType.UNPACKED*/,
2988
2992
  T: () => TrackSubscriptionDetails,
2989
2993
  },
2990
2994
  ]);
@@ -3010,7 +3014,7 @@ class JoinResponse$Type extends MessageType {
3010
3014
  no: 4,
3011
3015
  name: 'publish_options',
3012
3016
  kind: 'message',
3013
- repeat: 1 /*RepeatType.PACKED*/,
3017
+ repeat: 2 /*RepeatType.UNPACKED*/,
3014
3018
  T: () => PublishOption,
3015
3019
  },
3016
3020
  ]);
@@ -3092,7 +3096,7 @@ class ConnectionQualityChanged$Type extends MessageType {
3092
3096
  no: 1,
3093
3097
  name: 'connection_quality_updates',
3094
3098
  kind: 'message',
3095
- repeat: 1 /*RepeatType.PACKED*/,
3099
+ repeat: 2 /*RepeatType.UNPACKED*/,
3096
3100
  T: () => ConnectionQualityInfo,
3097
3101
  },
3098
3102
  ]);
@@ -3161,7 +3165,7 @@ class AudioLevelChanged$Type extends MessageType {
3161
3165
  no: 1,
3162
3166
  name: 'audio_levels',
3163
3167
  kind: 'message',
3164
- repeat: 1 /*RepeatType.PACKED*/,
3168
+ repeat: 2 /*RepeatType.UNPACKED*/,
3165
3169
  T: () => AudioLevel,
3166
3170
  },
3167
3171
  ]);
@@ -3241,7 +3245,7 @@ class VideoSender$Type extends MessageType {
3241
3245
  no: 3,
3242
3246
  name: 'layers',
3243
3247
  kind: 'message',
3244
- repeat: 1 /*RepeatType.PACKED*/,
3248
+ repeat: 2 /*RepeatType.UNPACKED*/,
3245
3249
  T: () => VideoLayerSetting,
3246
3250
  },
3247
3251
  {
@@ -3275,14 +3279,14 @@ class ChangePublishQuality$Type extends MessageType {
3275
3279
  no: 1,
3276
3280
  name: 'audio_senders',
3277
3281
  kind: 'message',
3278
- repeat: 1 /*RepeatType.PACKED*/,
3282
+ repeat: 2 /*RepeatType.UNPACKED*/,
3279
3283
  T: () => AudioSender,
3280
3284
  },
3281
3285
  {
3282
3286
  no: 2,
3283
3287
  name: 'video_senders',
3284
3288
  kind: 'message',
3285
- repeat: 1 /*RepeatType.PACKED*/,
3289
+ repeat: 2 /*RepeatType.UNPACKED*/,
3286
3290
  T: () => VideoSender,
3287
3291
  },
3288
3292
  ]);
@@ -5474,6 +5478,21 @@ class CallState {
5474
5478
  }
5475
5479
  }
5476
5480
 
5481
+ /**
5482
+ * NegotiationError is thrown when there is an error during the negotiation process.
5483
+ * It extends the built-in Error class and includes an SfuError object for more details.
5484
+ */
5485
+ class NegotiationError extends Error {
5486
+ /**
5487
+ * Creates an instance of NegotiationError.
5488
+ */
5489
+ constructor(error) {
5490
+ super(error.message);
5491
+ this.name = 'NegotiationError';
5492
+ this.error = error;
5493
+ }
5494
+ }
5495
+
5477
5496
  /**
5478
5497
  * Flatten the stats report into an array of stats objects.
5479
5498
  *
@@ -5792,7 +5811,7 @@ const aggregate = (stats) => {
5792
5811
  return report;
5793
5812
  };
5794
5813
 
5795
- const version = "1.24.0";
5814
+ const version = "1.25.0";
5796
5815
  const [major, minor, patch] = version.split('.');
5797
5816
  let sdkInfo = {
5798
5817
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6401,12 +6420,38 @@ class BasePeerConnection {
6401
6420
  /**
6402
6421
  * Constructs a new `BasePeerConnection` instance.
6403
6422
  */
6404
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onUnrecoverableError, logTag, enableTracing, }) {
6423
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, logTag, enableTracing, iceRestartDelay = 2500, }) {
6405
6424
  this.isIceRestarting = false;
6406
6425
  this.isDisposed = false;
6407
6426
  this.trackIdToTrackType = new Map();
6408
6427
  this.subscriptions = [];
6409
6428
  this.lock = Math.random().toString(36).slice(2);
6429
+ this.createPeerConnection = (connectionConfig) => {
6430
+ const pc = new RTCPeerConnection(connectionConfig);
6431
+ pc.addEventListener('icecandidate', this.onIceCandidate);
6432
+ pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6433
+ pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6434
+ pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
6435
+ pc.addEventListener('signalingstatechange', this.onSignalingChange);
6436
+ pc.addEventListener('connectionstatechange', this.onConnectionStateChange);
6437
+ return pc;
6438
+ };
6439
+ /**
6440
+ * Attempts to restart ICE on the `RTCPeerConnection`.
6441
+ * This method intentionally doesn't await the `restartIce()` method,
6442
+ * allowing it to run in the background and handle any errors that may occur.
6443
+ */
6444
+ this.tryRestartIce = () => {
6445
+ this.restartIce().catch((e) => {
6446
+ const reason = 'restartICE() failed, initiating reconnect';
6447
+ this.logger('error', reason, e);
6448
+ const strategy = e instanceof NegotiationError &&
6449
+ e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
6450
+ ? WebsocketReconnectStrategy.FAST
6451
+ : WebsocketReconnectStrategy.REJOIN;
6452
+ this.onReconnectionNeeded?.(strategy, reason);
6453
+ });
6454
+ };
6410
6455
  /**
6411
6456
  * Handles events synchronously.
6412
6457
  * Consecutive events are queued and executed one after the other.
@@ -6459,6 +6504,18 @@ class BasePeerConnection {
6459
6504
  this.getTrackType = (trackId) => {
6460
6505
  return this.trackIdToTrackType.get(trackId);
6461
6506
  };
6507
+ /**
6508
+ * Checks if the `RTCPeerConnection` is healthy.
6509
+ * It checks the ICE connection state and the peer connection state.
6510
+ * If either state is `failed`, `disconnected`, or `closed`,
6511
+ * it returns `false`, otherwise it returns `true`.
6512
+ */
6513
+ this.isHealthy = () => {
6514
+ const failedStates = new Set(['failed', 'closed']);
6515
+ const iceState = this.pc.iceConnectionState;
6516
+ const connectionState = this.pc.connectionState;
6517
+ return !failedStates.has(iceState) && !failedStates.has(connectionState);
6518
+ };
6462
6519
  /**
6463
6520
  * Handles the ICECandidate event and
6464
6521
  * Initiates an ICE Trickle process with the SFU.
@@ -6497,9 +6554,7 @@ class BasePeerConnection {
6497
6554
  this.onConnectionStateChange = async () => {
6498
6555
  const state = this.pc.connectionState;
6499
6556
  this.logger('debug', `Connection state changed`, state);
6500
- if (!this.tracer)
6501
- return;
6502
- if (state === 'connected' || state === 'failed') {
6557
+ if (this.tracer && (state === 'connected' || state === 'failed')) {
6503
6558
  try {
6504
6559
  const stats = await this.stats.get();
6505
6560
  this.tracer.trace('getstats', stats.delta);
@@ -6508,6 +6563,12 @@ class BasePeerConnection {
6508
6563
  this.tracer.trace('getstatsOnFailure', err.toString());
6509
6564
  }
6510
6565
  }
6566
+ // we can't recover from a failed connection state (contrary to ICE)
6567
+ if (state === 'failed') {
6568
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed');
6569
+ return;
6570
+ }
6571
+ this.handleConnectionStateUpdate(state);
6511
6572
  };
6512
6573
  /**
6513
6574
  * Handles the ICE connection state change event.
@@ -6515,34 +6576,53 @@ class BasePeerConnection {
6515
6576
  this.onIceConnectionStateChange = () => {
6516
6577
  const state = this.pc.iceConnectionState;
6517
6578
  this.logger('debug', `ICE connection state changed`, state);
6518
- if (this.state.callingState === CallingState.OFFLINE)
6579
+ this.handleConnectionStateUpdate(state);
6580
+ };
6581
+ this.handleConnectionStateUpdate = (state) => {
6582
+ const { callingState } = this.state;
6583
+ if (callingState === CallingState.OFFLINE)
6519
6584
  return;
6520
- if (this.state.callingState === CallingState.RECONNECTING)
6585
+ if (callingState === CallingState.RECONNECTING)
6521
6586
  return;
6522
6587
  // do nothing when ICE is restarting
6523
6588
  if (this.isIceRestarting)
6524
6589
  return;
6525
- if (state === 'failed') {
6526
- this.onUnrecoverableError?.('ICE connection failed');
6527
- }
6528
- else if (state === 'disconnected') {
6529
- this.logger('debug', `Attempting to restart ICE`);
6530
- this.restartIce().catch((e) => {
6531
- const reason = `ICE restart failed`;
6532
- this.logger('error', reason, e);
6533
- this.onUnrecoverableError?.(`${reason}: ${e}`);
6534
- });
6590
+ switch (state) {
6591
+ case 'failed':
6592
+ // in the `failed` state, we try to restart ICE immediately
6593
+ this.logger('info', 'restartICE due to failed connection');
6594
+ this.tryRestartIce();
6595
+ break;
6596
+ case 'disconnected':
6597
+ // in the `disconnected` state, we schedule a restartICE() after a delay
6598
+ // as the browser might recover the connection in the meantime
6599
+ this.logger('info', 'disconnected connection, scheduling restartICE');
6600
+ clearTimeout(this.iceRestartTimeout);
6601
+ this.iceRestartTimeout = setTimeout(() => {
6602
+ const currentState = this.pc.iceConnectionState;
6603
+ if (currentState === 'disconnected' || currentState === 'failed') {
6604
+ this.tryRestartIce();
6605
+ }
6606
+ }, this.iceRestartDelay);
6607
+ break;
6608
+ case 'connected':
6609
+ // in the `connected` state, we clear the ice restart timeout if it exists
6610
+ if (this.iceRestartTimeout) {
6611
+ this.logger('info', 'connected connection, canceling restartICE');
6612
+ clearTimeout(this.iceRestartTimeout);
6613
+ this.iceRestartTimeout = undefined;
6614
+ }
6615
+ break;
6535
6616
  }
6536
6617
  };
6537
6618
  /**
6538
6619
  * Handles the ICE candidate error event.
6539
6620
  */
6540
6621
  this.onIceCandidateError = (e) => {
6541
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6542
- `${e.errorCode}: ${e.errorText}`;
6543
- const iceState = this.pc.iceConnectionState;
6544
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6545
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6622
+ const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent
6623
+ ? `${e.errorCode}: ${e.errorText}`
6624
+ : e;
6625
+ this.logger('debug', 'ICE Candidate error', errorMessage);
6546
6626
  };
6547
6627
  /**
6548
6628
  * Handles the ICE gathering state change event.
@@ -6560,18 +6640,13 @@ class BasePeerConnection {
6560
6640
  this.sfuClient = sfuClient;
6561
6641
  this.state = state;
6562
6642
  this.dispatcher = dispatcher;
6563
- this.onUnrecoverableError = onUnrecoverableError;
6643
+ this.iceRestartDelay = iceRestartDelay;
6644
+ this.onReconnectionNeeded = onReconnectionNeeded;
6564
6645
  this.logger = getLogger([
6565
6646
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
6566
6647
  logTag,
6567
6648
  ]);
6568
- this.pc = new RTCPeerConnection(connectionConfig);
6569
- this.pc.addEventListener('icecandidate', this.onIceCandidate);
6570
- this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6571
- this.pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6572
- this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
6573
- this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
6574
- this.pc.addEventListener('connectionstatechange', this.onConnectionStateChange);
6649
+ this.pc = this.createPeerConnection(connectionConfig);
6575
6650
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
6576
6651
  if (enableTracing) {
6577
6652
  const tag = `${logTag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`;
@@ -6587,7 +6662,9 @@ class BasePeerConnection {
6587
6662
  * Disposes the `RTCPeerConnection` instance.
6588
6663
  */
6589
6664
  dispose() {
6590
- this.onUnrecoverableError = undefined;
6665
+ clearTimeout(this.iceRestartTimeout);
6666
+ this.iceRestartTimeout = undefined;
6667
+ this.onReconnectionNeeded = undefined;
6591
6668
  this.isDisposed = true;
6592
6669
  this.detachEventHandlers();
6593
6670
  this.pc.close();
@@ -6597,11 +6674,12 @@ class BasePeerConnection {
6597
6674
  * Detaches the event handlers from the `RTCPeerConnection`.
6598
6675
  */
6599
6676
  detachEventHandlers() {
6600
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
6601
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6602
- this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
6603
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6604
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
6677
+ const pc = this.pc;
6678
+ pc.removeEventListener('icecandidate', this.onIceCandidate);
6679
+ pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6680
+ pc.removeEventListener('signalingstatechange', this.onSignalingChange);
6681
+ pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6682
+ pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
6605
6683
  this.unsubscribeIceTrickle?.();
6606
6684
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
6607
6685
  }
@@ -6911,6 +6989,45 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
6911
6989
  return '';
6912
6990
  return String(transceiverInitIndex);
6913
6991
  };
6992
+ /**
6993
+ * Enables stereo in the answer SDP based on the offered stereo in the offer SDP.
6994
+ *
6995
+ * @param offerSdp the offer SDP containing the stereo configuration.
6996
+ * @param answerSdp the answer SDP to be modified.
6997
+ */
6998
+ const enableStereo = (offerSdp, answerSdp) => {
6999
+ const offeredStereoMids = new Set();
7000
+ const parsedOfferSdp = parse(offerSdp);
7001
+ for (const media of parsedOfferSdp.media) {
7002
+ if (media.type !== 'audio')
7003
+ continue;
7004
+ const opus = media.rtp.find((r) => r.codec === 'opus');
7005
+ if (!opus)
7006
+ continue;
7007
+ for (const fmtp of media.fmtp) {
7008
+ if (fmtp.payload === opus.payload && fmtp.config.includes('stereo=1')) {
7009
+ offeredStereoMids.add(media.mid);
7010
+ }
7011
+ }
7012
+ }
7013
+ // No stereo offered, return the original answerSdp
7014
+ if (offeredStereoMids.size === 0)
7015
+ return answerSdp;
7016
+ const parsedAnswerSdp = parse(answerSdp);
7017
+ for (const media of parsedAnswerSdp.media) {
7018
+ if (media.type !== 'audio' || !offeredStereoMids.has(media.mid))
7019
+ continue;
7020
+ const opus = media.rtp.find((r) => r.codec === 'opus');
7021
+ if (!opus)
7022
+ continue;
7023
+ for (const fmtp of media.fmtp) {
7024
+ if (fmtp.payload === opus.payload && !fmtp.config.includes('stereo=1')) {
7025
+ fmtp.config += ';stereo=1';
7026
+ }
7027
+ }
7028
+ }
7029
+ return write(parsedAnswerSdp);
7030
+ };
6914
7031
 
6915
7032
  /**
6916
7033
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
@@ -7024,11 +7141,11 @@ class Publisher extends BasePeerConnection {
7024
7141
  /**
7025
7142
  * Returns true if the given track type is currently being published to the SFU.
7026
7143
  *
7027
- * @param trackType the track type to check.
7144
+ * @param trackType the track type to check. If omitted, checks if any track is being published.
7028
7145
  */
7029
7146
  this.isPublishing = (trackType) => {
7030
7147
  for (const item of this.transceiverCache.items()) {
7031
- if (item.publishOption.trackType !== trackType)
7148
+ if (trackType && item.publishOption.trackType !== trackType)
7032
7149
  continue;
7033
7150
  const track = item.transceiver.sender.track;
7034
7151
  if (!track)
@@ -7151,10 +7268,17 @@ class Publisher extends BasePeerConnection {
7151
7268
  const { sdp = '' } = offer;
7152
7269
  const { response } = await this.sfuClient.setPublisher({ sdp, tracks });
7153
7270
  if (response.error)
7154
- throw new Error(response.error.message);
7271
+ throw new NegotiationError(response.error);
7155
7272
  const { sdp: answerSdp } = response;
7156
7273
  await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
7157
7274
  }
7275
+ catch (err) {
7276
+ // negotiation failed, rollback to the previous state
7277
+ if (this.pc.signalingState === 'have-local-offer') {
7278
+ await this.pc.setLocalDescription({ type: 'rollback' });
7279
+ }
7280
+ throw err;
7281
+ }
7158
7282
  finally {
7159
7283
  this.isIceRestarting = false;
7160
7284
  }
@@ -7246,11 +7370,7 @@ class Publisher extends BasePeerConnection {
7246
7370
  this.on('iceRestart', (iceRestart) => {
7247
7371
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
7248
7372
  return;
7249
- this.restartIce().catch((err) => {
7250
- const reason = `ICE restart failed`;
7251
- this.logger('warn', reason, err);
7252
- this.onUnrecoverableError?.(`${reason}: ${err}`);
7253
- });
7373
+ this.tryRestartIce();
7254
7374
  });
7255
7375
  this.on('changePublishQuality', async (event) => {
7256
7376
  for (const videoSender of event.videoSenders) {
@@ -7298,11 +7418,13 @@ class Subscriber extends BasePeerConnection {
7298
7418
  return;
7299
7419
  }
7300
7420
  const previousIsIceRestarting = this.isIceRestarting;
7421
+ this.isIceRestarting = true;
7301
7422
  try {
7302
- this.isIceRestarting = true;
7303
- await this.sfuClient.iceRestart({
7423
+ const { response } = await this.sfuClient.iceRestart({
7304
7424
  peerType: PeerType.SUBSCRIBER,
7305
7425
  });
7426
+ if (response.error)
7427
+ throw new NegotiationError(response.error);
7306
7428
  }
7307
7429
  catch (e) {
7308
7430
  // restore the previous state, as our intent for restarting ICE failed
@@ -7371,6 +7493,9 @@ class Subscriber extends BasePeerConnection {
7371
7493
  });
7372
7494
  this.addTrickledIceCandidates();
7373
7495
  const answer = await this.pc.createAnswer();
7496
+ if (answer.sdp) {
7497
+ answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
7498
+ }
7374
7499
  await this.pc.setLocalDescription(answer);
7375
7500
  await this.sfuClient.sendAnswer({
7376
7501
  peerType: PeerType.SUBSCRIBER,
@@ -7660,11 +7785,15 @@ class StreamSfuClient {
7660
7785
  */
7661
7786
  this.isLeaving = false;
7662
7787
  /**
7663
- * Flag to indicate if the client is in the process of closing the connection.
7788
+ * Flag to indicate if the client is in the process of clean closing the connection.
7789
+ * When set to `true`, the client will not attempt to reconnect
7790
+ * and will close the WebSocket connection gracefully.
7791
+ * Otherwise, it will close the connection with an error code and
7792
+ * trigger a reconnection attempt.
7664
7793
  */
7665
- this.isClosing = false;
7666
- this.pingIntervalInMs = 10 * 1000;
7667
- this.unhealthyTimeoutInMs = this.pingIntervalInMs + 5 * 1000;
7794
+ this.isClosingClean = false;
7795
+ this.pingIntervalInMs = 5 * 1000;
7796
+ this.unhealthyTimeoutInMs = 15 * 1000;
7668
7797
  /**
7669
7798
  * Promise that resolves when the JoinResponse is received.
7670
7799
  * Rejects after a certain threshold if the response is not received.
@@ -7708,7 +7837,7 @@ class StreamSfuClient {
7708
7837
  // Normally, this shouldn't have any effect, because WS should never emit 'close'
7709
7838
  // before emitting 'open'. However, strager things have happened, and we don't
7710
7839
  // want to leave signalReady in pending state.
7711
- reject(new Error('SFU WS closed unexpectedly'));
7840
+ reject(new Error(`SFU WS closed or connection can't be established`));
7712
7841
  });
7713
7842
  }),
7714
7843
  new Promise((resolve, reject) => {
@@ -7726,7 +7855,7 @@ class StreamSfuClient {
7726
7855
  this.onSignalClose?.(`${e.code} ${e.reason}`);
7727
7856
  };
7728
7857
  this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
7729
- this.isClosing = true;
7858
+ this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
7730
7859
  if (this.signalWs.readyState === WebSocket.OPEN) {
7731
7860
  this.logger('debug', `Closing SFU WS connection: ${code} - ${reason}`);
7732
7861
  this.signalWs.close(code, `js-client: ${reason}`);
@@ -7966,7 +8095,11 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
7966
8095
  * Here, we don't use 1000 (normal closure) because we don't want the
7967
8096
  * SFU to clean up the resources associated with the current participant.
7968
8097
  */
7969
- StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
8098
+ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8099
+ /**
8100
+ * The close code used when the client fails to join the call (on the SFU).
8101
+ */
8102
+ StreamSfuClient.JOIN_FAILED = 4101;
7970
8103
 
7971
8104
  /**
7972
8105
  * Event handler that watched the delivery of `call.accepted`.
@@ -9480,21 +9613,30 @@ let getDisplayMediaExecId = 0;
9480
9613
  const getScreenShareStream = async (options, tracer) => {
9481
9614
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
9482
9615
  try {
9483
- tracer?.trace(tag, options);
9484
- const stream = await navigator.mediaDevices.getDisplayMedia({
9485
- video: true,
9486
- audio: {
9487
- channelCount: {
9488
- ideal: 2,
9489
- },
9490
- echoCancellation: false,
9491
- autoGainControl: false,
9492
- noiseSuppression: false,
9493
- },
9616
+ const constraints = {
9494
9617
  // @ts-expect-error - not present in types yet
9495
9618
  systemAudio: 'include',
9496
9619
  ...options,
9497
- });
9620
+ video: typeof options?.video === 'boolean'
9621
+ ? options.video // must be 'true'
9622
+ : {
9623
+ width: { max: 2560 },
9624
+ height: { max: 1440 },
9625
+ frameRate: { ideal: 30 },
9626
+ ...options?.video,
9627
+ },
9628
+ audio: typeof options?.audio === 'boolean'
9629
+ ? options.audio
9630
+ : {
9631
+ channelCount: { ideal: 2 },
9632
+ echoCancellation: false,
9633
+ autoGainControl: false,
9634
+ noiseSuppression: false,
9635
+ ...options?.audio,
9636
+ },
9637
+ };
9638
+ tracer?.trace(tag, constraints);
9639
+ const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
9498
9640
  tracer?.trace(`${tag}OnSuccess`, dumpStream(stream));
9499
9641
  return stream;
9500
9642
  }
@@ -10842,11 +10984,18 @@ class ScreenShareManager extends InputMediaDeviceManager {
10842
10984
  getDevices() {
10843
10985
  return of([]); // there are no devices to be listed for Screen Share
10844
10986
  }
10845
- getStream(constraints) {
10987
+ async getStream(constraints) {
10846
10988
  if (!this.state.audioEnabled) {
10847
10989
  constraints.audio = false;
10848
10990
  }
10849
- return getScreenShareStream(constraints, this.call.tracer);
10991
+ const stream = await getScreenShareStream(constraints, this.call.tracer);
10992
+ const [track] = stream.getVideoTracks();
10993
+ const { contentHint } = this.state.settings || {};
10994
+ if (typeof contentHint !== 'undefined' && track && 'contentHint' in track) {
10995
+ this.call.tracer.trace('navigator.mediaDevices.getDisplayMedia.contentHint', contentHint);
10996
+ track.contentHint = contentHint;
10997
+ }
10998
+ return stream;
10850
10999
  }
10851
11000
  async stopPublishStream() {
10852
11001
  return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
@@ -11241,10 +11390,13 @@ class Call {
11241
11390
  * Leave the call and stop the media streams that were published by the call.
11242
11391
  */
11243
11392
  this.leave = async ({ reject, reason, message } = {}) => {
11393
+ if (this.state.callingState === CallingState.LEFT) {
11394
+ throw new Error('Cannot leave call that has already been left.');
11395
+ }
11244
11396
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11245
11397
  const callingState = this.state.callingState;
11246
11398
  if (callingState === CallingState.LEFT) {
11247
- throw new Error('Cannot leave call that has already been left.');
11399
+ return;
11248
11400
  }
11249
11401
  if (callingState === CallingState.JOINING) {
11250
11402
  const waitUntilCallJoined = () => {
@@ -11351,7 +11503,6 @@ class Call {
11351
11503
  this.state.setMembers(response.members);
11352
11504
  this.state.setOwnCapabilities(response.own_capabilities);
11353
11505
  if (params?.ring) {
11354
- // the call response can indicate where the call is still ringing or not
11355
11506
  this.ringingSubject.next(true);
11356
11507
  }
11357
11508
  if (this.streamClient._hasConnectionID()) {
@@ -11373,7 +11524,6 @@ class Call {
11373
11524
  this.state.setMembers(response.members);
11374
11525
  this.state.setOwnCapabilities(response.own_capabilities);
11375
11526
  if (data?.ring) {
11376
- // the call response can indicate where the call is still ringing or not
11377
11527
  this.ringingSubject.next(true);
11378
11528
  }
11379
11529
  if (this.streamClient._hasConnectionID()) {
@@ -11553,7 +11703,7 @@ class Call {
11553
11703
  }
11554
11704
  catch (error) {
11555
11705
  this.logger('warn', 'Join SFU request failed', error);
11556
- sfuClient.close(StreamSfuClient.ERROR_CONNECTION_UNHEALTHY, 'Join request failed, connection considered unhealthy');
11706
+ sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
11557
11707
  // restore the previous call state if the join-flow fails
11558
11708
  this.state.setCallingState(callingState);
11559
11709
  throw error;
@@ -11696,7 +11846,7 @@ class Call {
11696
11846
  }
11697
11847
  if (this.publisher) {
11698
11848
  this.publisher.setSfuClient(nextSfuClient);
11699
- if (includePublisher) {
11849
+ if (includePublisher && this.publisher.isPublishing()) {
11700
11850
  await this.publisher.restartIce();
11701
11851
  }
11702
11852
  }
@@ -11718,9 +11868,10 @@ class Call {
11718
11868
  connectionConfig,
11719
11869
  logTag: String(this.sfuClientTag),
11720
11870
  enableTracing,
11721
- onUnrecoverableError: (reason) => {
11722
- this.reconnect(WebsocketReconnectStrategy.REJOIN, reason).catch((err) => {
11723
- this.logger('warn', `[Reconnect] Error reconnecting after a subscriber error: ${reason}`, err);
11871
+ onReconnectionNeeded: (kind, reason) => {
11872
+ this.reconnect(kind, reason).catch((err) => {
11873
+ const message = `[Reconnect] Error reconnecting after a subscriber error: ${reason}`;
11874
+ this.logger('warn', message, err);
11724
11875
  });
11725
11876
  },
11726
11877
  });
@@ -11739,9 +11890,10 @@ class Call {
11739
11890
  publishOptions,
11740
11891
  logTag: String(this.sfuClientTag),
11741
11892
  enableTracing,
11742
- onUnrecoverableError: (reason) => {
11743
- this.reconnect(WebsocketReconnectStrategy.REJOIN, reason).catch((err) => {
11744
- this.logger('warn', `[Reconnect] Error reconnecting after a publisher error: ${reason}`, err);
11893
+ onReconnectionNeeded: (kind, reason) => {
11894
+ this.reconnect(kind, reason).catch((err) => {
11895
+ const message = `[Reconnect] Error reconnecting after a publisher error: ${reason}`;
11896
+ this.logger('warn', message, err);
11745
11897
  });
11746
11898
  },
11747
11899
  });
@@ -11825,9 +11977,12 @@ class Call {
11825
11977
  callingState === CallingState.LEFT)
11826
11978
  return;
11827
11979
  // normal close, no need to reconnect
11828
- if (sfuClient.isLeaving || sfuClient.isClosing)
11980
+ if (sfuClient.isLeaving || sfuClient.isClosingClean)
11829
11981
  return;
11830
- this.reconnect(WebsocketReconnectStrategy.REJOIN, reason).catch((err) => {
11982
+ const strategy = this.publisher?.isHealthy() && this.subscriber?.isHealthy()
11983
+ ? WebsocketReconnectStrategy.FAST
11984
+ : WebsocketReconnectStrategy.REJOIN;
11985
+ this.reconnect(strategy, reason).catch((err) => {
11831
11986
  this.logger('warn', '[Reconnect] Error reconnecting', err);
11832
11987
  });
11833
11988
  };
@@ -11848,10 +12003,12 @@ class Call {
11848
12003
  const reconnectStartTime = Date.now();
11849
12004
  this.reconnectStrategy = strategy;
11850
12005
  this.reconnectReason = reason;
12006
+ let attempt = 0;
11851
12007
  do {
11852
- if (this.disconnectionTimeoutSeconds > 0 &&
11853
- (Date.now() - reconnectStartTime) / 1000 >
11854
- this.disconnectionTimeoutSeconds) {
12008
+ const reconnectingTime = Date.now() - reconnectStartTime;
12009
+ const shouldGiveUpReconnecting = this.disconnectionTimeoutSeconds > 0 &&
12010
+ reconnectingTime / 1000 > this.disconnectionTimeoutSeconds;
12011
+ if (shouldGiveUpReconnecting) {
11855
12012
  this.logger('warn', '[Reconnect] Stopping reconnection attempts after reaching disconnection timeout');
11856
12013
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
11857
12014
  return;
@@ -11860,7 +12017,7 @@ class Call {
11860
12017
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
11861
12018
  this.reconnectAttempts++;
11862
12019
  }
11863
- const current = WebsocketReconnectStrategy[this.reconnectStrategy];
12020
+ const currentStrategy = WebsocketReconnectStrategy[this.reconnectStrategy];
11864
12021
  try {
11865
12022
  // wait until the network is available
11866
12023
  await this.networkAvailableTask?.promise;
@@ -11868,7 +12025,7 @@ class Call {
11868
12025
  switch (this.reconnectStrategy) {
11869
12026
  case WebsocketReconnectStrategy.UNSPECIFIED:
11870
12027
  case WebsocketReconnectStrategy.DISCONNECT:
11871
- this.logger('debug', `[Reconnect] No-op strategy ${current}`);
12028
+ this.logger('debug', `[Reconnect] No-op strategy ${currentStrategy}`);
11872
12029
  break;
11873
12030
  case WebsocketReconnectStrategy.FAST:
11874
12031
  await this.reconnectFast();
@@ -11887,7 +12044,7 @@ class Call {
11887
12044
  }
11888
12045
  catch (error) {
11889
12046
  if (this.state.callingState === CallingState.OFFLINE) {
11890
- this.logger('trace', `[Reconnect] Can't reconnect while offline, stopping reconnection attempts`);
12047
+ this.logger('debug', `[Reconnect] Can't reconnect while offline, stopping reconnection attempts`);
11891
12048
  break;
11892
12049
  // we don't need to handle the error if the call is offline
11893
12050
  // network change event will trigger the reconnection
@@ -11897,9 +12054,24 @@ class Call {
11897
12054
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
11898
12055
  return;
11899
12056
  }
11900
- this.logger('warn', `[Reconnect] ${current} (${this.reconnectAttempts}) failed. Attempting with REJOIN`, error);
11901
12057
  await sleep(500);
11902
- this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
12058
+ const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
12059
+ const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
12060
+ this.fastReconnectDeadlineSeconds;
12061
+ // don't immediately switch to the REJOIN strategy, but instead attempt
12062
+ // to reconnect with the FAST strategy for a few times before switching.
12063
+ // in some cases, we immediately switch to the REJOIN strategy.
12064
+ const shouldRejoin = mustPerformRejoin || // if we are past the fast reconnect deadline
12065
+ wasMigrating || // if we were migrating, but the migration failed
12066
+ attempt >= 3 || // after 3 failed attempts
12067
+ !(this.publisher?.isHealthy() ?? true) || // if the publisher is not healthy
12068
+ !(this.subscriber?.isHealthy() ?? true); // if the subscriber is not healthy
12069
+ attempt++;
12070
+ const nextStrategy = shouldRejoin
12071
+ ? WebsocketReconnectStrategy.REJOIN
12072
+ : WebsocketReconnectStrategy.FAST;
12073
+ this.reconnectStrategy = nextStrategy;
12074
+ this.logger('info', `[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
11903
12075
  }
11904
12076
  } while (this.state.callingState !== CallingState.JOINED &&
11905
12077
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
@@ -12912,6 +13084,38 @@ class Call {
12912
13084
  }
12913
13085
  }
12914
13086
 
13087
+ const APIErrorCodes = {
13088
+ [-1]: 'InternalSystemError',
13089
+ 2: 'AccessKeyError',
13090
+ 3: 'AuthenticationFailedError',
13091
+ 4: 'InputError',
13092
+ 5: 'AuthenticationError',
13093
+ 6: 'DuplicateUsernameError',
13094
+ 9: 'RateLimitError',
13095
+ 16: 'DoesNotExistError',
13096
+ 17: 'NotAllowedError',
13097
+ 18: 'EventNotSupportedError',
13098
+ 19: 'ChannelFeatureNotSupportedError',
13099
+ 20: 'MessageTooLongError',
13100
+ 21: 'MultipleNestingLevelError',
13101
+ 22: 'PayloadTooBigError',
13102
+ 23: 'RequestTimeoutError',
13103
+ 24: 'MaxHeaderSizeExceededError',
13104
+ 40: 'AuthErrorTokenExpired',
13105
+ 41: 'AuthErrorTokenNotValidYet',
13106
+ 42: 'AuthErrorTokenUsedBeforeIssuedAt',
13107
+ 43: 'AuthErrorTokenSignatureInvalid',
13108
+ 44: 'CustomCommandEndpointMissingError',
13109
+ 45: 'CustomCommandEndpointCallError',
13110
+ 46: 'ConnectionIDNotFoundError',
13111
+ 60: 'CoolDownError',
13112
+ 69: 'ErrWrongRegion',
13113
+ 70: 'ErrQueryChannelPermissions',
13114
+ 71: 'ErrTooManyConnections',
13115
+ 73: 'MessageModerationFailedError',
13116
+ 99: 'AppSuspendedError',
13117
+ };
13118
+
12915
13119
  /**
12916
13120
  * StableWSConnection - A WS connection that reconnects upon failure.
12917
13121
  * - the browser will sometimes report that you're online or offline
@@ -13138,7 +13342,7 @@ class StableWSConnection {
13138
13342
  message = error.message;
13139
13343
  statusCode = error.StatusCode;
13140
13344
  }
13141
- const msg = `WS failed with code: ${code} and reason: ${message}`;
13345
+ const msg = `WS failed with code: ${code}: ${APIErrorCodes[code] || code} and reason: ${message}`;
13142
13346
  this._log(msg, { event }, 'warn');
13143
13347
  const error = new Error(msg);
13144
13348
  error.code = code;
@@ -14016,7 +14220,7 @@ class StreamClient {
14016
14220
  this.getUserAgent = () => {
14017
14221
  if (!this.cachedUserAgent) {
14018
14222
  const { clientAppIdentifier = {} } = this.options;
14019
- const { sdkName = 'js', sdkVersion = "1.24.0", ...extras } = clientAppIdentifier;
14223
+ const { sdkName = 'js', sdkVersion = "1.25.0", ...extras } = clientAppIdentifier;
14020
14224
  this.cachedUserAgent = [
14021
14225
  `stream-video-${sdkName}-v${sdkVersion}`,
14022
14226
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -14366,13 +14570,17 @@ class StreamVideoClient {
14366
14570
  * @param type the type of the call.
14367
14571
  * @param id the id of the call.
14368
14572
  */
14369
- this.call = (type, id) => {
14370
- return new Call({
14371
- streamClient: this.streamClient,
14372
- id: id,
14373
- type: type,
14374
- clientStore: this.writeableStateStore,
14375
- });
14573
+ this.call = (type, id, options = {}) => {
14574
+ const call = options.reuseInstance
14575
+ ? this.writeableStateStore.findCall(type, id)
14576
+ : undefined;
14577
+ return (call ??
14578
+ new Call({
14579
+ streamClient: this.streamClient,
14580
+ id: id,
14581
+ type: type,
14582
+ clientStore: this.writeableStateStore,
14583
+ }));
14376
14584
  };
14377
14585
  /**
14378
14586
  * Creates a new guest user with the given data.