@stream-io/video-client 1.24.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.browser.es.js +367 -128
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +366 -127
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +367 -128
  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/devices/InputMediaDeviceManager.d.ts +2 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +1 -0
  13. package/dist/src/devices/ScreenShareManager.d.ts +1 -0
  14. package/dist/src/devices/SpeakerManager.d.ts +2 -0
  15. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  16. package/dist/src/rtc/BasePeerConnection.d.ts +23 -4
  17. package/dist/src/rtc/NegotiationError.d.ts +15 -0
  18. package/dist/src/rtc/Publisher.d.ts +2 -2
  19. package/dist/src/rtc/helpers/sdp.d.ts +7 -0
  20. package/dist/src/types.d.ts +11 -0
  21. package/package.json +1 -1
  22. package/src/Call.ts +72 -38
  23. package/src/StreamSfuClient.ts +17 -7
  24. package/src/StreamVideoClient.ts +17 -7
  25. package/src/coordinator/connection/connection.ts +2 -1
  26. package/src/coordinator/connection/errors.ts +31 -0
  27. package/src/devices/CameraManagerState.ts +1 -1
  28. package/src/devices/InputMediaDeviceManager.ts +13 -0
  29. package/src/devices/MicrophoneManager.ts +3 -0
  30. package/src/devices/ScreenShareManager.ts +18 -5
  31. package/src/devices/SpeakerManager.ts +13 -0
  32. package/src/devices/devices.ts +23 -12
  33. package/src/events/__tests__/internal.test.ts +1 -0
  34. package/src/gen/google/protobuf/struct.ts +2 -2
  35. package/src/gen/google/protobuf/timestamp.ts +1 -1
  36. package/src/gen/video/sfu/event/events.ts +15 -15
  37. package/src/gen/video/sfu/models/models.ts +9 -5
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +1 -1
  39. package/src/gen/video/sfu/signal_rpc/signal.ts +6 -6
  40. package/src/rtc/BasePeerConnection.ts +132 -46
  41. package/src/rtc/NegotiationError.ts +21 -0
  42. package/src/rtc/Publisher.ts +12 -9
  43. package/src/rtc/Subscriber.ts +8 -2
  44. package/src/rtc/__tests__/Publisher.test.ts +160 -17
  45. package/src/rtc/__tests__/Subscriber.test.ts +31 -14
  46. package/src/rtc/helpers/__tests__/sdp.stereo.test.ts +120 -0
  47. package/src/rtc/helpers/sdp.ts +43 -1
  48. 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.1";
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
  }
@@ -9542,6 +9684,7 @@ class InputMediaDeviceManager {
9542
9684
  */
9543
9685
  this.stopOnLeave = true;
9544
9686
  this.subscriptions = [];
9687
+ this.areSubscriptionsSetUp = false;
9545
9688
  this.isTrackStoppedDueToTrackEnd = false;
9546
9689
  this.filters = [];
9547
9690
  this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
@@ -9553,11 +9696,20 @@ class InputMediaDeviceManager {
9553
9696
  */
9554
9697
  this.dispose = () => {
9555
9698
  this.subscriptions.forEach((s) => s());
9699
+ this.subscriptions = [];
9700
+ this.areSubscriptionsSetUp = false;
9556
9701
  };
9557
9702
  this.call = call;
9558
9703
  this.state = state;
9559
9704
  this.trackType = trackType;
9560
9705
  this.logger = getLogger([`${TrackType[trackType].toLowerCase()} manager`]);
9706
+ this.setup();
9707
+ }
9708
+ setup() {
9709
+ if (this.areSubscriptionsSetUp) {
9710
+ return;
9711
+ }
9712
+ this.areSubscriptionsSetUp = true;
9561
9713
  if (deviceIds$ &&
9562
9714
  !isReactNative() &&
9563
9715
  (this.trackType === TrackType.AUDIO || this.trackType === TrackType.VIDEO)) {
@@ -10518,6 +10670,9 @@ class MicrophoneManager extends InputMediaDeviceManager {
10518
10670
  super(call, new MicrophoneManagerState(disableMode), TrackType.AUDIO);
10519
10671
  this.speakingWhileMutedNotificationEnabled = true;
10520
10672
  this.soundDetectorConcurrencyTag = Symbol('soundDetectorConcurrencyTag');
10673
+ }
10674
+ setup() {
10675
+ super.setup();
10521
10676
  this.subscriptions.push(createSafeAsyncSubscription(combineLatest([
10522
10677
  this.call.state.callingState$,
10523
10678
  this.call.state.ownCapabilities$,
@@ -10795,7 +10950,10 @@ class ScreenShareState extends InputMediaDeviceManagerState {
10795
10950
  class ScreenShareManager extends InputMediaDeviceManager {
10796
10951
  constructor(call) {
10797
10952
  super(call, new ScreenShareState(), TrackType.SCREEN_SHARE);
10798
- this.subscriptions.push(createSubscription(call.state.settings$, (settings) => {
10953
+ }
10954
+ setup() {
10955
+ super.setup();
10956
+ this.subscriptions.push(createSubscription(this.call.state.settings$, (settings) => {
10799
10957
  const maybeTargetResolution = settings?.screensharing.target_resolution;
10800
10958
  if (maybeTargetResolution) {
10801
10959
  this.setDefaultConstraints({
@@ -10842,11 +11000,18 @@ class ScreenShareManager extends InputMediaDeviceManager {
10842
11000
  getDevices() {
10843
11001
  return of([]); // there are no devices to be listed for Screen Share
10844
11002
  }
10845
- getStream(constraints) {
11003
+ async getStream(constraints) {
10846
11004
  if (!this.state.audioEnabled) {
10847
11005
  constraints.audio = false;
10848
11006
  }
10849
- return getScreenShareStream(constraints, this.call.tracer);
11007
+ const stream = await getScreenShareStream(constraints, this.call.tracer);
11008
+ const [track] = stream.getVideoTracks();
11009
+ const { contentHint } = this.state.settings || {};
11010
+ if (typeof contentHint !== 'undefined' && track && 'contentHint' in track) {
11011
+ this.call.tracer.trace('navigator.mediaDevices.getDisplayMedia.contentHint', contentHint);
11012
+ track.contentHint = contentHint;
11013
+ }
11014
+ return stream;
10850
11015
  }
10851
11016
  async stopPublishStream() {
10852
11017
  return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
@@ -10911,6 +11076,7 @@ class SpeakerState {
10911
11076
  class SpeakerManager {
10912
11077
  constructor(call) {
10913
11078
  this.subscriptions = [];
11079
+ this.areSubscriptionsSetUp = false;
10914
11080
  /**
10915
11081
  * Disposes the manager.
10916
11082
  *
@@ -10918,9 +11084,18 @@ class SpeakerManager {
10918
11084
  */
10919
11085
  this.dispose = () => {
10920
11086
  this.subscriptions.forEach((s) => s.unsubscribe());
11087
+ this.subscriptions = [];
11088
+ this.areSubscriptionsSetUp = false;
10921
11089
  };
10922
11090
  this.call = call;
10923
11091
  this.state = new SpeakerState(call.tracer);
11092
+ this.setup();
11093
+ }
11094
+ setup() {
11095
+ if (this.areSubscriptionsSetUp) {
11096
+ return;
11097
+ }
11098
+ this.areSubscriptionsSetUp = true;
10924
11099
  if (deviceIds$ && !isReactNative()) {
10925
11100
  this.subscriptions.push(combineLatest([deviceIds$, this.state.selectedDevice$]).subscribe(([devices, deviceId]) => {
10926
11101
  if (!deviceId) {
@@ -11062,6 +11237,10 @@ class Call {
11062
11237
  this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
11063
11238
  this.registerEffects();
11064
11239
  this.registerReconnectHandlers();
11240
+ this.camera.setup();
11241
+ this.microphone.setup();
11242
+ this.screenShare.setup();
11243
+ this.speaker.setup();
11065
11244
  if (this.state.callingState === CallingState.LEFT) {
11066
11245
  this.state.setCallingState(CallingState.IDLE);
11067
11246
  }
@@ -11241,10 +11420,13 @@ class Call {
11241
11420
  * Leave the call and stop the media streams that were published by the call.
11242
11421
  */
11243
11422
  this.leave = async ({ reject, reason, message } = {}) => {
11423
+ if (this.state.callingState === CallingState.LEFT) {
11424
+ throw new Error('Cannot leave call that has already been left.');
11425
+ }
11244
11426
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11245
11427
  const callingState = this.state.callingState;
11246
11428
  if (callingState === CallingState.LEFT) {
11247
- throw new Error('Cannot leave call that has already been left.');
11429
+ return;
11248
11430
  }
11249
11431
  if (callingState === CallingState.JOINING) {
11250
11432
  const waitUntilCallJoined = () => {
@@ -11297,6 +11479,7 @@ class Call {
11297
11479
  this.microphone.dispose();
11298
11480
  this.screenShare.dispose();
11299
11481
  this.speaker.dispose();
11482
+ this.deviceSettingsAppliedOnce = false;
11300
11483
  const stopOnLeavePromises = [];
11301
11484
  if (this.camera.stopOnLeave) {
11302
11485
  stopOnLeavePromises.push(this.camera.disable(true));
@@ -11351,7 +11534,6 @@ class Call {
11351
11534
  this.state.setMembers(response.members);
11352
11535
  this.state.setOwnCapabilities(response.own_capabilities);
11353
11536
  if (params?.ring) {
11354
- // the call response can indicate where the call is still ringing or not
11355
11537
  this.ringingSubject.next(true);
11356
11538
  }
11357
11539
  if (this.streamClient._hasConnectionID()) {
@@ -11373,7 +11555,6 @@ class Call {
11373
11555
  this.state.setMembers(response.members);
11374
11556
  this.state.setOwnCapabilities(response.own_capabilities);
11375
11557
  if (data?.ring) {
11376
- // the call response can indicate where the call is still ringing or not
11377
11558
  this.ringingSubject.next(true);
11378
11559
  }
11379
11560
  if (this.streamClient._hasConnectionID()) {
@@ -11553,7 +11734,7 @@ class Call {
11553
11734
  }
11554
11735
  catch (error) {
11555
11736
  this.logger('warn', 'Join SFU request failed', error);
11556
- sfuClient.close(StreamSfuClient.ERROR_CONNECTION_UNHEALTHY, 'Join request failed, connection considered unhealthy');
11737
+ sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
11557
11738
  // restore the previous call state if the join-flow fails
11558
11739
  this.state.setCallingState(callingState);
11559
11740
  throw error;
@@ -11696,7 +11877,7 @@ class Call {
11696
11877
  }
11697
11878
  if (this.publisher) {
11698
11879
  this.publisher.setSfuClient(nextSfuClient);
11699
- if (includePublisher) {
11880
+ if (includePublisher && this.publisher.isPublishing()) {
11700
11881
  await this.publisher.restartIce();
11701
11882
  }
11702
11883
  }
@@ -11718,9 +11899,10 @@ class Call {
11718
11899
  connectionConfig,
11719
11900
  logTag: String(this.sfuClientTag),
11720
11901
  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);
11902
+ onReconnectionNeeded: (kind, reason) => {
11903
+ this.reconnect(kind, reason).catch((err) => {
11904
+ const message = `[Reconnect] Error reconnecting after a subscriber error: ${reason}`;
11905
+ this.logger('warn', message, err);
11724
11906
  });
11725
11907
  },
11726
11908
  });
@@ -11739,9 +11921,10 @@ class Call {
11739
11921
  publishOptions,
11740
11922
  logTag: String(this.sfuClientTag),
11741
11923
  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);
11924
+ onReconnectionNeeded: (kind, reason) => {
11925
+ this.reconnect(kind, reason).catch((err) => {
11926
+ const message = `[Reconnect] Error reconnecting after a publisher error: ${reason}`;
11927
+ this.logger('warn', message, err);
11745
11928
  });
11746
11929
  },
11747
11930
  });
@@ -11825,9 +12008,12 @@ class Call {
11825
12008
  callingState === CallingState.LEFT)
11826
12009
  return;
11827
12010
  // normal close, no need to reconnect
11828
- if (sfuClient.isLeaving || sfuClient.isClosing)
12011
+ if (sfuClient.isLeaving || sfuClient.isClosingClean)
11829
12012
  return;
11830
- this.reconnect(WebsocketReconnectStrategy.REJOIN, reason).catch((err) => {
12013
+ const strategy = this.publisher?.isHealthy() && this.subscriber?.isHealthy()
12014
+ ? WebsocketReconnectStrategy.FAST
12015
+ : WebsocketReconnectStrategy.REJOIN;
12016
+ this.reconnect(strategy, reason).catch((err) => {
11831
12017
  this.logger('warn', '[Reconnect] Error reconnecting', err);
11832
12018
  });
11833
12019
  };
@@ -11848,10 +12034,12 @@ class Call {
11848
12034
  const reconnectStartTime = Date.now();
11849
12035
  this.reconnectStrategy = strategy;
11850
12036
  this.reconnectReason = reason;
12037
+ let attempt = 0;
11851
12038
  do {
11852
- if (this.disconnectionTimeoutSeconds > 0 &&
11853
- (Date.now() - reconnectStartTime) / 1000 >
11854
- this.disconnectionTimeoutSeconds) {
12039
+ const reconnectingTime = Date.now() - reconnectStartTime;
12040
+ const shouldGiveUpReconnecting = this.disconnectionTimeoutSeconds > 0 &&
12041
+ reconnectingTime / 1000 > this.disconnectionTimeoutSeconds;
12042
+ if (shouldGiveUpReconnecting) {
11855
12043
  this.logger('warn', '[Reconnect] Stopping reconnection attempts after reaching disconnection timeout');
11856
12044
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
11857
12045
  return;
@@ -11860,7 +12048,7 @@ class Call {
11860
12048
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
11861
12049
  this.reconnectAttempts++;
11862
12050
  }
11863
- const current = WebsocketReconnectStrategy[this.reconnectStrategy];
12051
+ const currentStrategy = WebsocketReconnectStrategy[this.reconnectStrategy];
11864
12052
  try {
11865
12053
  // wait until the network is available
11866
12054
  await this.networkAvailableTask?.promise;
@@ -11868,7 +12056,7 @@ class Call {
11868
12056
  switch (this.reconnectStrategy) {
11869
12057
  case WebsocketReconnectStrategy.UNSPECIFIED:
11870
12058
  case WebsocketReconnectStrategy.DISCONNECT:
11871
- this.logger('debug', `[Reconnect] No-op strategy ${current}`);
12059
+ this.logger('debug', `[Reconnect] No-op strategy ${currentStrategy}`);
11872
12060
  break;
11873
12061
  case WebsocketReconnectStrategy.FAST:
11874
12062
  await this.reconnectFast();
@@ -11887,7 +12075,7 @@ class Call {
11887
12075
  }
11888
12076
  catch (error) {
11889
12077
  if (this.state.callingState === CallingState.OFFLINE) {
11890
- this.logger('trace', `[Reconnect] Can't reconnect while offline, stopping reconnection attempts`);
12078
+ this.logger('debug', `[Reconnect] Can't reconnect while offline, stopping reconnection attempts`);
11891
12079
  break;
11892
12080
  // we don't need to handle the error if the call is offline
11893
12081
  // network change event will trigger the reconnection
@@ -11897,9 +12085,24 @@ class Call {
11897
12085
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
11898
12086
  return;
11899
12087
  }
11900
- this.logger('warn', `[Reconnect] ${current} (${this.reconnectAttempts}) failed. Attempting with REJOIN`, error);
11901
12088
  await sleep(500);
11902
- this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
12089
+ const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
12090
+ const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
12091
+ this.fastReconnectDeadlineSeconds;
12092
+ // don't immediately switch to the REJOIN strategy, but instead attempt
12093
+ // to reconnect with the FAST strategy for a few times before switching.
12094
+ // in some cases, we immediately switch to the REJOIN strategy.
12095
+ const shouldRejoin = mustPerformRejoin || // if we are past the fast reconnect deadline
12096
+ wasMigrating || // if we were migrating, but the migration failed
12097
+ attempt >= 3 || // after 3 failed attempts
12098
+ !(this.publisher?.isHealthy() ?? true) || // if the publisher is not healthy
12099
+ !(this.subscriber?.isHealthy() ?? true); // if the subscriber is not healthy
12100
+ attempt++;
12101
+ const nextStrategy = shouldRejoin
12102
+ ? WebsocketReconnectStrategy.REJOIN
12103
+ : WebsocketReconnectStrategy.FAST;
12104
+ this.reconnectStrategy = nextStrategy;
12105
+ this.logger('info', `[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
11903
12106
  }
11904
12107
  } while (this.state.callingState !== CallingState.JOINED &&
11905
12108
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
@@ -12912,6 +13115,38 @@ class Call {
12912
13115
  }
12913
13116
  }
12914
13117
 
13118
+ const APIErrorCodes = {
13119
+ [-1]: 'InternalSystemError',
13120
+ 2: 'AccessKeyError',
13121
+ 3: 'AuthenticationFailedError',
13122
+ 4: 'InputError',
13123
+ 5: 'AuthenticationError',
13124
+ 6: 'DuplicateUsernameError',
13125
+ 9: 'RateLimitError',
13126
+ 16: 'DoesNotExistError',
13127
+ 17: 'NotAllowedError',
13128
+ 18: 'EventNotSupportedError',
13129
+ 19: 'ChannelFeatureNotSupportedError',
13130
+ 20: 'MessageTooLongError',
13131
+ 21: 'MultipleNestingLevelError',
13132
+ 22: 'PayloadTooBigError',
13133
+ 23: 'RequestTimeoutError',
13134
+ 24: 'MaxHeaderSizeExceededError',
13135
+ 40: 'AuthErrorTokenExpired',
13136
+ 41: 'AuthErrorTokenNotValidYet',
13137
+ 42: 'AuthErrorTokenUsedBeforeIssuedAt',
13138
+ 43: 'AuthErrorTokenSignatureInvalid',
13139
+ 44: 'CustomCommandEndpointMissingError',
13140
+ 45: 'CustomCommandEndpointCallError',
13141
+ 46: 'ConnectionIDNotFoundError',
13142
+ 60: 'CoolDownError',
13143
+ 69: 'ErrWrongRegion',
13144
+ 70: 'ErrQueryChannelPermissions',
13145
+ 71: 'ErrTooManyConnections',
13146
+ 73: 'MessageModerationFailedError',
13147
+ 99: 'AppSuspendedError',
13148
+ };
13149
+
12915
13150
  /**
12916
13151
  * StableWSConnection - A WS connection that reconnects upon failure.
12917
13152
  * - the browser will sometimes report that you're online or offline
@@ -13138,7 +13373,7 @@ class StableWSConnection {
13138
13373
  message = error.message;
13139
13374
  statusCode = error.StatusCode;
13140
13375
  }
13141
- const msg = `WS failed with code: ${code} and reason: ${message}`;
13376
+ const msg = `WS failed with code: ${code}: ${APIErrorCodes[code] || code} and reason: ${message}`;
13142
13377
  this._log(msg, { event }, 'warn');
13143
13378
  const error = new Error(msg);
13144
13379
  error.code = code;
@@ -14016,7 +14251,7 @@ class StreamClient {
14016
14251
  this.getUserAgent = () => {
14017
14252
  if (!this.cachedUserAgent) {
14018
14253
  const { clientAppIdentifier = {} } = this.options;
14019
- const { sdkName = 'js', sdkVersion = "1.24.0", ...extras } = clientAppIdentifier;
14254
+ const { sdkName = 'js', sdkVersion = "1.25.1", ...extras } = clientAppIdentifier;
14020
14255
  this.cachedUserAgent = [
14021
14256
  `stream-video-${sdkName}-v${sdkVersion}`,
14022
14257
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -14366,13 +14601,17 @@ class StreamVideoClient {
14366
14601
  * @param type the type of the call.
14367
14602
  * @param id the id of the call.
14368
14603
  */
14369
- this.call = (type, id) => {
14370
- return new Call({
14371
- streamClient: this.streamClient,
14372
- id: id,
14373
- type: type,
14374
- clientStore: this.writeableStateStore,
14375
- });
14604
+ this.call = (type, id, options = {}) => {
14605
+ const call = options.reuseInstance
14606
+ ? this.writeableStateStore.findCall(type, id)
14607
+ : undefined;
14608
+ return (call ??
14609
+ new Call({
14610
+ streamClient: this.streamClient,
14611
+ id: id,
14612
+ type: type,
14613
+ clientStore: this.writeableStateStore,
14614
+ }));
14376
14615
  };
14377
14616
  /**
14378
14617
  * Creates a new guest user with the given data.