@stream-io/video-client 1.5.0-0 → 1.5.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 (76) hide show
  1. package/CHANGELOG.md +6 -230
  2. package/dist/index.browser.es.js +1498 -1963
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1495 -1961
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1498 -1963
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +9 -93
  9. package/dist/src/StreamSfuClient.d.ts +56 -72
  10. package/dist/src/StreamVideoClient.d.ts +10 -2
  11. package/dist/src/coordinator/connection/client.d.ts +4 -3
  12. package/dist/src/coordinator/connection/types.d.ts +1 -5
  13. package/dist/src/devices/InputMediaDeviceManager.d.ts +0 -4
  14. package/dist/src/devices/MicrophoneManager.d.ts +1 -1
  15. package/dist/src/events/callEventHandlers.d.ts +3 -1
  16. package/dist/src/events/internal.d.ts +0 -4
  17. package/dist/src/gen/video/sfu/event/events.d.ts +4 -106
  18. package/dist/src/gen/video/sfu/models/models.d.ts +65 -64
  19. package/dist/src/logger.d.ts +0 -1
  20. package/dist/src/rpc/createClient.d.ts +0 -2
  21. package/dist/src/rpc/index.d.ts +0 -1
  22. package/dist/src/rtc/Dispatcher.d.ts +1 -1
  23. package/dist/src/rtc/IceTrickleBuffer.d.ts +1 -0
  24. package/dist/src/rtc/Publisher.d.ts +25 -24
  25. package/dist/src/rtc/Subscriber.d.ts +11 -12
  26. package/dist/src/rtc/flows/join.d.ts +20 -0
  27. package/dist/src/rtc/helpers/tracks.d.ts +3 -3
  28. package/dist/src/rtc/signal.d.ts +1 -1
  29. package/dist/src/store/CallState.d.ts +2 -46
  30. package/package.json +3 -3
  31. package/src/Call.ts +562 -615
  32. package/src/StreamSfuClient.ts +246 -277
  33. package/src/StreamVideoClient.ts +65 -15
  34. package/src/coordinator/connection/client.ts +8 -25
  35. package/src/coordinator/connection/connection.ts +0 -1
  36. package/src/coordinator/connection/token_manager.ts +1 -1
  37. package/src/coordinator/connection/types.ts +0 -6
  38. package/src/devices/BrowserPermission.ts +1 -5
  39. package/src/devices/CameraManager.ts +1 -1
  40. package/src/devices/InputMediaDeviceManager.ts +3 -12
  41. package/src/devices/MicrophoneManager.ts +3 -3
  42. package/src/devices/devices.ts +1 -1
  43. package/src/events/__tests__/mutes.test.ts +13 -10
  44. package/src/events/__tests__/participant.test.ts +0 -75
  45. package/src/events/callEventHandlers.ts +7 -4
  46. package/src/events/internal.ts +3 -20
  47. package/src/events/mutes.ts +3 -5
  48. package/src/events/participant.ts +15 -48
  49. package/src/gen/video/sfu/event/events.ts +8 -451
  50. package/src/gen/video/sfu/models/models.ts +204 -211
  51. package/src/logger.ts +1 -3
  52. package/src/rpc/createClient.ts +0 -21
  53. package/src/rpc/index.ts +0 -1
  54. package/src/rtc/Dispatcher.ts +2 -6
  55. package/src/rtc/IceTrickleBuffer.ts +2 -2
  56. package/src/rtc/Publisher.ts +163 -127
  57. package/src/rtc/Subscriber.ts +155 -94
  58. package/src/rtc/__tests__/Publisher.test.ts +95 -18
  59. package/src/rtc/__tests__/Subscriber.test.ts +99 -63
  60. package/src/rtc/__tests__/videoLayers.test.ts +2 -2
  61. package/src/rtc/flows/join.ts +65 -0
  62. package/src/rtc/helpers/tracks.ts +7 -27
  63. package/src/rtc/signal.ts +3 -3
  64. package/src/rtc/videoLayers.ts +10 -1
  65. package/src/stats/SfuStatsReporter.ts +0 -1
  66. package/src/store/CallState.ts +2 -109
  67. package/src/store/__tests__/CallState.test.ts +37 -48
  68. package/dist/src/helpers/ensureExhausted.d.ts +0 -1
  69. package/dist/src/helpers/withResolvers.d.ts +0 -14
  70. package/dist/src/rpc/retryable.d.ts +0 -23
  71. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +0 -2
  72. package/src/helpers/ensureExhausted.ts +0 -5
  73. package/src/helpers/withResolvers.ts +0 -43
  74. package/src/rpc/__tests__/retryable.test.ts +0 -72
  75. package/src/rpc/retryable.ts +0 -57
  76. package/src/rtc/helpers/rtcConfiguration.ts +0 -11
package/src/Call.ts CHANGED
@@ -7,12 +7,12 @@ import {
7
7
  Subscriber,
8
8
  } from './rtc';
9
9
  import { muteTypeToTrackType } from './rtc/helpers/tracks';
10
- import { toRtcConfiguration } from './rtc/helpers/rtcConfiguration';
11
10
  import {
12
11
  hasScreenShare,
13
12
  hasScreenShareAudio,
14
13
  hasVideo,
15
14
  } from './helpers/participantUtils';
15
+ import { GoAwayReason, TrackType } from './gen/video/sfu/models/models';
16
16
  import {
17
17
  registerEventHandlers,
18
18
  registerRingingCallEventHandlers,
@@ -22,18 +22,13 @@ import {
22
22
  CallState,
23
23
  StreamVideoWriteableStateStore,
24
24
  } from './store';
25
+ import { createSubscription, getCurrentValue } from './store/rxUtils';
25
26
  import {
26
- createSafeAsyncSubscription,
27
- createSubscription,
28
- getCurrentValue,
29
- } from './store/rxUtils';
30
- import type {
31
27
  AcceptCallResponse,
32
28
  BlockUserRequest,
33
29
  BlockUserResponse,
34
30
  CollectUserFeedbackRequest,
35
31
  CollectUserFeedbackResponse,
36
- Credentials,
37
32
  EndCallResponse,
38
33
  GetCallResponse,
39
34
  GetCallStatsResponse,
@@ -41,12 +36,11 @@ import type {
41
36
  GetOrCreateCallResponse,
42
37
  GoLiveRequest,
43
38
  GoLiveResponse,
44
- JoinCallRequest,
45
- JoinCallResponse,
46
39
  ListRecordingsResponse,
47
40
  ListTranscriptionsResponse,
48
41
  MuteUsersRequest,
49
42
  MuteUsersResponse,
43
+ OwnCapability,
50
44
  PinRequest,
51
45
  PinResponse,
52
46
  QueryCallMembersRequest,
@@ -59,6 +53,7 @@ import type {
59
53
  SendCallEventResponse,
60
54
  SendReactionRequest,
61
55
  SendReactionResponse,
56
+ SFUResponse,
62
57
  StartHLSBroadcastingResponse,
63
58
  StartRecordingRequest,
64
59
  StartRecordingResponse,
@@ -80,7 +75,7 @@ import type {
80
75
  UpdateUserPermissionsRequest,
81
76
  UpdateUserPermissionsResponse,
82
77
  } from './gen/coordinator';
83
- import { OwnCapability } from './gen/coordinator';
78
+ import { join } from './rtc/flows/join';
84
79
  import {
85
80
  AudioTrackType,
86
81
  CallConstructor,
@@ -93,6 +88,7 @@ import {
93
88
  SubscriptionChanges,
94
89
  TrackMuteType,
95
90
  VideoTrackType,
91
+ VisibilityState,
96
92
  } from './types';
97
93
  import {
98
94
  BehaviorSubject,
@@ -105,21 +101,22 @@ import {
105
101
  } from 'rxjs';
106
102
  import { TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
107
103
  import {
108
- ReconnectDetails,
104
+ JoinResponse,
105
+ Migration,
109
106
  VideoLayerSetting,
110
107
  } from './gen/video/sfu/event/events';
111
- import {
112
- ClientDetails,
113
- TrackType,
114
- WebsocketReconnectStrategy,
115
- } from './gen/video/sfu/models/models';
108
+ import { Timestamp } from './gen/google/protobuf/timestamp';
116
109
  import { createStatsReporter, SfuStatsReporter, StatsReporter } from './stats';
117
110
  import { DynascaleManager } from './helpers/DynascaleManager';
118
111
  import { PermissionsContext } from './permissions';
119
112
  import { CallTypes } from './CallType';
120
113
  import { StreamClient } from './coordinator/connection/client';
121
- import { retryInterval, sleep } from './coordinator/connection/utils';
122
- import type {
114
+ import {
115
+ KnownCodes,
116
+ retryInterval,
117
+ sleep,
118
+ } from './coordinator/connection/utils';
119
+ import {
123
120
  AllCallEvents,
124
121
  CallEventListener,
125
122
  Logger,
@@ -137,11 +134,6 @@ import {
137
134
  } from './devices';
138
135
  import { getSdkSignature } from './stats/utils';
139
136
  import { withoutConcurrency } from './helpers/concurrency';
140
- import { ensureExhausted } from './helpers/ensureExhausted';
141
- import {
142
- PromiseWithResolvers,
143
- promiseWithResolvers,
144
- } from './helpers/withResolvers';
145
137
 
146
138
  /**
147
139
  * An object representation of a `Call`.
@@ -230,22 +222,9 @@ export class Call {
230
222
  private readonly clientStore: StreamVideoWriteableStateStore;
231
223
  public readonly streamClient: StreamClient;
232
224
  private sfuClient?: StreamSfuClient;
233
- private sfuClientTag = 0;
234
-
235
- private readonly reconnectConcurrencyTag = Symbol('reconnectConcurrencyTag');
236
225
  private reconnectAttempts = 0;
237
- private reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
238
- private fastReconnectDeadlineSeconds: number = 0;
239
- private lastOfflineTimestamp: number = 0;
240
- private networkAvailableTask: PromiseWithResolvers<void> | undefined;
241
- // maintain the order of publishing tracks to restore them after a reconnection
242
- // it shouldn't contain duplicates
243
- private trackPublishOrder: TrackType[] = [];
244
- private joinCallData?: JoinCallData;
245
- private hasJoinedOnce = false;
246
- private deviceSettingsAppliedOnce = false;
247
- private credentials?: Credentials;
248
-
226
+ private maxReconnectAttempts = 10;
227
+ private isLeaving = false;
249
228
  private initialized = false;
250
229
  private readonly joinLeaveConcurrencyTag = Symbol('joinLeaveConcurrencyTag');
251
230
 
@@ -308,7 +287,9 @@ export class Call {
308
287
 
309
288
  private async setup() {
310
289
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
311
- if (this.initialized) return;
290
+ if (this.initialized) {
291
+ return;
292
+ }
312
293
 
313
294
  this.leaveCallHooks.add(
314
295
  this.on('all', (event) => {
@@ -317,9 +298,10 @@ export class Call {
317
298
  }),
318
299
  );
319
300
 
320
- this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
301
+ this.leaveCallHooks.add(
302
+ registerEventHandlers(this, this.state, this.dispatcher),
303
+ );
321
304
  this.registerEffects();
322
- this.registerReconnectHandlers();
323
305
 
324
306
  this.leaveCallHooks.add(
325
307
  createSubscription(
@@ -353,10 +335,71 @@ export class Call {
353
335
 
354
336
  this.leaveCallHooks.add(
355
337
  // handle the case when the user permissions are modified.
356
- createSafeAsyncSubscription(
357
- this.state.ownCapabilities$,
358
- this.handleOwnCapabilitiesUpdated,
359
- ),
338
+ createSubscription(this.state.ownCapabilities$, (ownCapabilities) => {
339
+ // update the permission context.
340
+ this.permissionsContext.setPermissions(ownCapabilities);
341
+
342
+ if (!this.publisher) return;
343
+
344
+ // check if the user still has publishing permissions and stop publishing if not.
345
+ const permissionToTrackType = {
346
+ [OwnCapability.SEND_AUDIO]: TrackType.AUDIO,
347
+ [OwnCapability.SEND_VIDEO]: TrackType.VIDEO,
348
+ [OwnCapability.SCREENSHARE]: TrackType.SCREEN_SHARE,
349
+ };
350
+ for (const [permission, trackType] of Object.entries(
351
+ permissionToTrackType,
352
+ )) {
353
+ const hasPermission = this.permissionsContext.hasPermission(
354
+ permission as OwnCapability,
355
+ );
356
+ if (
357
+ !hasPermission &&
358
+ (this.publisher.isPublishing(trackType) ||
359
+ this.publisher.isLive(trackType))
360
+ ) {
361
+ // Stop tracks, then notify device manager
362
+ this.stopPublish(trackType)
363
+ .catch((err) => {
364
+ this.logger(
365
+ 'error',
366
+ `Error stopping publish ${trackType}`,
367
+ err,
368
+ );
369
+ })
370
+ .then(() => {
371
+ if (
372
+ trackType === TrackType.VIDEO &&
373
+ this.camera.state.status === 'enabled'
374
+ ) {
375
+ this.camera
376
+ .disable()
377
+ .catch((err) =>
378
+ this.logger(
379
+ 'error',
380
+ `Error disabling camera after permission revoked`,
381
+ err,
382
+ ),
383
+ );
384
+ }
385
+ if (
386
+ trackType === TrackType.AUDIO &&
387
+ this.microphone.state.status === 'enabled'
388
+ ) {
389
+ this.microphone
390
+ .disable()
391
+ .catch((err) =>
392
+ this.logger(
393
+ 'error',
394
+ `Error disabling microphone after permission revoked`,
395
+ err,
396
+ ),
397
+ );
398
+ }
399
+ });
400
+ }
401
+ }
402
+ }),
360
403
  );
361
404
 
362
405
  this.leaveCallHooks.add(
@@ -433,49 +476,6 @@ export class Call {
433
476
  );
434
477
  }
435
478
 
436
- private handleOwnCapabilitiesUpdated = async (
437
- ownCapabilities: OwnCapability[],
438
- ) => {
439
- // update the permission context.
440
- this.permissionsContext.setPermissions(ownCapabilities);
441
-
442
- if (!this.publisher) return;
443
-
444
- // check if the user still has publishing permissions and stop publishing if not.
445
- const permissionToTrackType = {
446
- [OwnCapability.SEND_AUDIO]: TrackType.AUDIO,
447
- [OwnCapability.SEND_VIDEO]: TrackType.VIDEO,
448
- [OwnCapability.SCREENSHARE]: TrackType.SCREEN_SHARE,
449
- };
450
- for (const [permission, trackType] of Object.entries(
451
- permissionToTrackType,
452
- )) {
453
- const hasPermission = this.permissionsContext.hasPermission(
454
- permission as OwnCapability,
455
- );
456
- if (hasPermission) continue;
457
- try {
458
- switch (trackType) {
459
- case TrackType.AUDIO:
460
- if (this.microphone.enabled) await this.microphone.disable();
461
- break;
462
- case TrackType.VIDEO:
463
- if (this.camera.enabled) await this.camera.disable();
464
- break;
465
- case TrackType.SCREEN_SHARE:
466
- if (this.screenShare.enabled) await this.screenShare.disable();
467
- break;
468
- }
469
- } catch (err) {
470
- this.logger(
471
- 'error',
472
- `Can't disable mic/camera/screenshare after revoked permissions`,
473
- err,
474
- );
475
- }
476
- }
477
- };
478
-
479
479
  /**
480
480
  * You can subscribe to WebSocket events provided by the API. To remove a subscription, call the `off` method.
481
481
  * Please note that subscribing to WebSocket events is an advanced use-case.
@@ -541,9 +541,11 @@ export class Call {
541
541
  }
542
542
 
543
543
  if (callingState === CallingState.JOINING) {
544
- await this.waitUntilCallJoined();
544
+ await this.assertCallJoined();
545
545
  }
546
546
 
547
+ this.isLeaving = true;
548
+
547
549
  if (this.ringing) {
548
550
  // I'm the one who started the call, so I should cancel it.
549
551
  const hasOtherParticipants = this.state.remoteParticipants.length > 0;
@@ -570,10 +572,10 @@ export class Call {
570
572
  this.subscriber?.close();
571
573
  this.subscriber = undefined;
572
574
 
573
- this.publisher?.close({ stopTracks: true });
575
+ this.publisher?.close();
574
576
  this.publisher = undefined;
575
577
 
576
- await this.sfuClient?.leaveAndClose(reason);
578
+ this.sfuClient?.close(StreamSfuClient.NORMAL_CLOSURE, reason);
577
579
  this.sfuClient = undefined;
578
580
 
579
581
  this.state.setCallingState(CallingState.LEFT);
@@ -581,7 +583,6 @@ export class Call {
581
583
  // Call all leave call hooks, e.g. to clean up global event handlers
582
584
  this.leaveCallHooks.forEach((hook) => hook());
583
585
  this.initialized = false;
584
- this.hasJoinedOnce = false;
585
586
  this.clientStore.unregisterCall(this);
586
587
 
587
588
  this.camera.dispose();
@@ -656,7 +657,7 @@ export class Call {
656
657
  this.clientStore.registerCall(this);
657
658
  }
658
659
 
659
- await this.applyDeviceConfig(false);
660
+ await this.applyDeviceConfig();
660
661
 
661
662
  return response;
662
663
  };
@@ -687,7 +688,7 @@ export class Call {
687
688
  this.clientStore.registerCall(this);
688
689
  }
689
690
 
690
- await this.applyDeviceConfig(false);
691
+ await this.applyDeviceConfig();
691
692
 
692
693
  return response;
693
694
  };
@@ -755,218 +756,326 @@ export class Call {
755
756
  await this.setup();
756
757
  const callingState = this.state.callingState;
757
758
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
758
- throw new Error(`Illegal State: call.join() shall be called only once`);
759
+ this.logger(
760
+ 'warn',
761
+ 'Join method called twice, you should only call this once',
762
+ );
763
+ throw new Error(`Illegal State: Already joined.`);
759
764
  }
760
765
 
761
- this.joinCallData = data;
762
-
763
- this.logger('debug', 'Starting join flow');
766
+ const isMigrating = callingState === CallingState.MIGRATING;
767
+ const isReconnecting = callingState === CallingState.RECONNECTING;
764
768
  this.state.setCallingState(CallingState.JOINING);
769
+ this.logger('debug', 'Starting join flow');
765
770
 
766
- const performingMigration =
767
- this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
768
- const performingRejoin =
769
- this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
770
- const performingFastReconnect =
771
- this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
771
+ if (data?.ring && !this.ringing) {
772
+ this.ringingSubject.next(true);
773
+ }
772
774
 
773
- let statsOptions = this.sfuStatsReporter?.options;
774
- if (
775
- !this.credentials ||
776
- !statsOptions ||
777
- performingRejoin ||
778
- performingMigration
779
- ) {
780
- try {
781
- const joinResponse = await this.doJoinRequest(data);
782
- this.credentials = joinResponse.credentials;
783
- statsOptions = joinResponse.stats_options;
784
- } catch (error) {
785
- // restore the previous call state if the join-flow fails
786
- this.state.setCallingState(callingState);
787
- throw error;
775
+ if (this.ringing && !this.isCreatedByMe) {
776
+ // signals other users that I have accepted the incoming call.
777
+ await this.accept();
778
+ }
779
+
780
+ let sfuServer: SFUResponse;
781
+ let sfuToken: string;
782
+ let connectionConfig: RTCConfiguration | undefined;
783
+ let statsOptions: StatsOptions | undefined;
784
+ try {
785
+ if (this.sfuClient?.isFastReconnecting) {
786
+ // use previous SFU configuration and values
787
+ connectionConfig = this.publisher?.connectionConfiguration;
788
+ sfuServer = this.sfuClient.sfuServer;
789
+ sfuToken = this.sfuClient.token;
790
+ statsOptions = this.sfuStatsReporter?.options;
791
+ } else {
792
+ // full join flow - let the Coordinator pick a new SFU for us
793
+ const call = await join(this.streamClient, this.type, this.id, data);
794
+ this.state.updateFromCallResponse(call.metadata);
795
+ this.state.setMembers(call.members);
796
+ this.state.setOwnCapabilities(call.ownCapabilities);
797
+ connectionConfig = call.connectionConfig;
798
+ sfuServer = call.sfuServer;
799
+ sfuToken = call.token;
800
+ statsOptions = call.statsOptions;
788
801
  }
802
+
803
+ if (this.streamClient._hasConnectionID()) {
804
+ this.watching = true;
805
+ this.clientStore.registerCall(this);
806
+ }
807
+ } catch (error) {
808
+ // restore the previous call state if the join-flow fails
809
+ this.state.setCallingState(callingState);
810
+ throw error;
789
811
  }
790
812
 
791
813
  const previousSfuClient = this.sfuClient;
792
- const previousSessionId = previousSfuClient?.sessionId;
793
- const isWsHealthy = !!previousSfuClient?.isHealthy;
794
- const sfuClient =
795
- performingRejoin || performingMigration || !isWsHealthy
796
- ? new StreamSfuClient({
797
- logTag: String(this.sfuClientTag++),
798
- dispatcher: this.dispatcher,
799
- credentials: this.credentials,
800
- // a new session_id is necessary for the REJOIN strategy.
801
- // we use the previous session_id if available
802
- sessionId: performingRejoin ? undefined : previousSessionId,
803
- onSignalClose: () => this.handleSfuSignalClose(sfuClient),
804
- })
805
- : previousSfuClient;
806
- this.sfuClient = sfuClient;
807
-
808
- const clientDetails = getClientDetails();
809
- // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
810
- if (previousSfuClient !== sfuClient) {
811
- // prepare a generic SDP and send it to the SFU.
812
- // this is a throw-away SDP that the SFU will use to determine
813
- // the capabilities of the client (codec support, etc.)
814
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
815
- const reconnectDetails =
816
- this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
817
- ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
818
- : undefined;
819
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
820
- subscriberSdp: receivingCapabilitiesSdp,
821
- clientDetails,
822
- fastReconnect: performingFastReconnect,
823
- reconnectDetails,
824
- });
814
+ const sfuClient = (this.sfuClient = new StreamSfuClient({
815
+ dispatcher: this.dispatcher,
816
+ sfuServer,
817
+ token: sfuToken,
818
+ sessionId: previousSfuClient?.sessionId,
819
+ }));
820
+
821
+ /**
822
+ * A closure which hides away the re-connection logic.
823
+ */
824
+ const reconnect = async (
825
+ strategy: 'full' | 'fast' | 'migrate',
826
+ reason: string,
827
+ ): Promise<void> => {
828
+ const currentState = this.state.callingState;
829
+ if (
830
+ currentState === CallingState.MIGRATING ||
831
+ currentState === CallingState.RECONNECTING
832
+ ) {
833
+ // prevent parallel reconnection attempts
834
+ return;
835
+ }
836
+ this.reconnectAttempts++;
837
+ this.state.setCallingState(
838
+ strategy === 'migrate'
839
+ ? CallingState.MIGRATING
840
+ : CallingState.RECONNECTING,
841
+ );
825
842
 
826
- this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
827
- if (callState) {
828
- this.state.updateFromSfuCallState(
829
- callState,
830
- sfuClient.sessionId,
831
- reconnectDetails,
843
+ if (strategy === 'migrate') {
844
+ this.logger(
845
+ 'debug',
846
+ `[Migration]: migrating call ${this.cid} away from ${sfuServer.edge_name}`,
847
+ );
848
+ sfuClient.isMigratingAway = true;
849
+ } else {
850
+ this.logger(
851
+ 'debug',
852
+ `[Rejoin]: ${strategy} rejoin call ${this.cid} (${this.reconnectAttempts})...`,
832
853
  );
833
854
  }
834
- }
835
855
 
836
- this.state.setCallingState(CallingState.JOINED);
837
- this.hasJoinedOnce = true;
838
-
839
- // when performing fast reconnect, or when we reuse the same SFU client,
840
- // (ws remained healthy), we just need to restore the ICE connection
841
- if (performingFastReconnect) {
842
- // the SFU automatically issues an ICE restart on the subscriber
843
- // we don't have to do it ourselves
844
- await this.restoreICE(sfuClient, { includeSubscriber: false });
845
- } else {
846
- const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
847
- this.initPublisherAndSubscriber({
848
- sfuClient,
849
- connectionConfig,
850
- clientDetails,
851
- statsOptions,
852
- closePreviousInstances: !performingMigration,
856
+ // take a snapshot of the current "local participant" state
857
+ // we'll need it for restoring the previous publishing state later
858
+ const localParticipant = this.state.localParticipant;
859
+
860
+ if (strategy === 'fast') {
861
+ sfuClient.close(
862
+ StreamSfuClient.ERROR_CONNECTION_BROKEN,
863
+ `attempting fast reconnect: ${reason}`,
864
+ );
865
+ } else if (strategy === 'full') {
866
+ // in migration or recovery scenarios, we don't want to
867
+ // wait before attempting to reconnect to an SFU server
868
+ await sleep(retryInterval(this.reconnectAttempts));
869
+
870
+ // in full-reconnect, we need to dispose all Peer Connections
871
+ this.subscriber?.close();
872
+ this.subscriber = undefined;
873
+ this.publisher?.close({ stopTracks: false });
874
+ this.publisher = undefined;
875
+ this.statsReporter?.stop();
876
+ this.statsReporter = undefined;
877
+ this.sfuStatsReporter?.stop();
878
+ this.sfuStatsReporter = undefined;
879
+
880
+ // clean up current connection
881
+ sfuClient.close(
882
+ StreamSfuClient.NORMAL_CLOSURE,
883
+ `attempting full reconnect: ${reason}`,
884
+ );
885
+ }
886
+ await this.join({
887
+ ...data,
888
+ ...(strategy === 'migrate' && { migrating_from: sfuServer.edge_name }),
853
889
  });
854
- }
855
890
 
856
- if (performingRejoin) {
857
- const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
858
- await previousSfuClient?.leaveAndClose(
859
- `Closing previous WS after reconnect with strategy: ${strategy}`,
860
- );
861
- } else if (!isWsHealthy) {
862
- previousSfuClient?.close(4002, 'Closing unhealthy WS after reconnect');
863
- }
891
+ // clean up previous connection
892
+ if (strategy === 'migrate') {
893
+ sfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'attempting migration');
894
+ }
864
895
 
865
- // device settings should be applied only once, we don't have to
866
- // re-apply them on later reconnections or server-side data fetches
867
- if (!this.deviceSettingsAppliedOnce) {
868
- await this.applyDeviceConfig(true);
869
- this.deviceSettingsAppliedOnce = true;
870
- }
871
- this.logger('info', `Joined call ${this.cid}`);
872
- };
896
+ this.logger(
897
+ 'info',
898
+ `[Rejoin]: Attempt ${this.reconnectAttempts} successful!`,
899
+ );
900
+ // we shouldn't be republishing the streams if we're migrating
901
+ // as the underlying peer connection will take care of it as part
902
+ // of the ice-restart process
903
+ if (localParticipant && strategy === 'full') {
904
+ const {
905
+ audioStream,
906
+ videoStream,
907
+ screenShareStream,
908
+ screenShareAudioStream,
909
+ } = localParticipant;
910
+
911
+ let screenShare: MediaStream | undefined;
912
+ if (screenShareStream || screenShareAudioStream) {
913
+ screenShare = new MediaStream();
914
+ screenShareStream?.getVideoTracks().forEach((track) => {
915
+ screenShare?.addTrack(track);
916
+ });
917
+ screenShareAudioStream?.getAudioTracks().forEach((track) => {
918
+ screenShare?.addTrack(track);
919
+ });
920
+ }
873
921
 
874
- /**
875
- * Prepares Reconnect Details object.
876
- * @internal
877
- */
878
- private getReconnectDetails = (
879
- migratingFromSfuId: string | undefined,
880
- previousSessionId: string | undefined,
881
- ): ReconnectDetails => {
882
- const strategy = this.reconnectStrategy;
883
- const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
884
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
885
- const subscribedTracks = getCurrentValue(this.trackSubscriptionsSubject);
886
- return {
887
- strategy,
888
- announcedTracks,
889
- subscriptions: subscribedTracks.data || [],
890
- reconnectAttempt: this.reconnectAttempts,
891
- fromSfuId: migratingFromSfuId || '',
892
- previousSessionId: performingRejoin ? previousSessionId || '' : '',
893
- };
894
- };
922
+ // restore previous publishing state
923
+ if (audioStream) await this.publishAudioStream(audioStream);
924
+ if (videoStream) {
925
+ await this.publishVideoStream(videoStream, {
926
+ preferredCodec: this.camera.preferredCodec,
927
+ });
928
+ }
929
+ if (screenShare) await this.publishScreenShareStream(screenShare);
895
930
 
896
- /**
897
- * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
898
- * Uses the provided SFU client to restore the ICE connection.
899
- *
900
- * This method can throw an error if the ICE restart fails.
901
- * This error should be handled by the reconnect loop,
902
- * and a new reconnection shall be attempted.
903
- *
904
- * @internal
905
- */
906
- private restoreICE = async (
907
- nextSfuClient: StreamSfuClient,
908
- opts: { includeSubscriber?: boolean; includePublisher?: boolean } = {},
909
- ) => {
910
- const { includeSubscriber = true, includePublisher = true } = opts;
911
- if (this.subscriber) {
912
- this.subscriber.setSfuClient(nextSfuClient);
913
- if (includeSubscriber) {
914
- await this.subscriber.restartIce();
915
- }
916
- }
917
- if (this.publisher) {
918
- this.publisher.setSfuClient(nextSfuClient);
919
- if (includePublisher) {
920
- await this.publisher.restartIce();
931
+ this.logger(
932
+ 'info',
933
+ `[Rejoin]: State restored. Attempt: ${this.reconnectAttempts}`,
934
+ );
921
935
  }
922
- }
923
- };
936
+ };
924
937
 
925
- /**
926
- * Initializes the Publisher and Subscriber Peer Connections.
927
- * @internal
928
- */
929
- private initPublisherAndSubscriber = (opts: {
930
- sfuClient: StreamSfuClient;
931
- connectionConfig: RTCConfiguration;
932
- statsOptions: StatsOptions;
933
- clientDetails: ClientDetails;
934
- closePreviousInstances: boolean;
935
- }) => {
936
- const {
937
- sfuClient,
938
- connectionConfig,
939
- clientDetails,
940
- statsOptions,
941
- closePreviousInstances,
942
- } = opts;
943
- if (closePreviousInstances && this.subscriber) {
944
- this.subscriber.close();
945
- }
946
- this.subscriber = new Subscriber({
947
- sfuClient,
948
- dispatcher: this.dispatcher,
949
- state: this.state,
950
- connectionConfig,
951
- logTag: String(this.reconnectAttempts),
952
- onUnrecoverableError: () => {
953
- this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
938
+ // reconnect if the connection was closed unexpectedly. example:
939
+ // - SFU crash or restart
940
+ // - network change
941
+ sfuClient.signalReady.then(() => {
942
+ // register a handler for the "goAway" event
943
+ const unregisterGoAway = this.dispatcher.on('goAway', (event) => {
944
+ const { reason } = event;
945
+ this.logger(
946
+ 'info',
947
+ `[Migration]: Going away from SFU... Reason: ${GoAwayReason[reason]}`,
948
+ );
949
+ reconnect('migrate', GoAwayReason[reason]).catch((err) => {
954
950
  this.logger(
955
951
  'warn',
956
- '[Reconnect] Error reconnecting after a subscriber error',
952
+ `[Migration]: Failed to migrate to another SFU.`,
957
953
  err,
958
954
  );
959
955
  });
956
+ });
957
+
958
+ sfuClient.signalWs.addEventListener('close', (e) => {
959
+ // unregister the "goAway" handler, as we won't need it anymore for this connection.
960
+ // the upcoming re-join will register a new handler anyway
961
+ unregisterGoAway();
962
+ // when the user has initiated "call.leave()" operation, we shouldn't
963
+ // care for the WS close code and we shouldn't ever attempt to reconnect
964
+ if (this.isLeaving) return;
965
+ // do nothing if the connection was closed on purpose
966
+ if (e.code === StreamSfuClient.NORMAL_CLOSURE) return;
967
+ // do nothing if the connection was closed because of a policy violation
968
+ // e.g., the user has been blocked by an admin or moderator
969
+ if (e.code === KnownCodes.WS_POLICY_VIOLATION) return;
970
+ // When the SFU is being shut down, it sends a goAway message.
971
+ // While we migrate to another SFU, we might have the WS connection
972
+ // to the old SFU closed abruptly. In this case, we don't want
973
+ // to reconnect to the old SFU, but rather to the new one.
974
+ const isMigratingAway =
975
+ e.code === KnownCodes.WS_CLOSED_ABRUPTLY && sfuClient.isMigratingAway;
976
+ const isFastReconnecting =
977
+ e.code === KnownCodes.WS_CLOSED_ABRUPTLY &&
978
+ sfuClient.isFastReconnecting;
979
+ if (isMigratingAway || isFastReconnecting) return;
980
+
981
+ // do nothing if the connection was closed because of a fast reconnect
982
+ if (e.code === StreamSfuClient.ERROR_CONNECTION_BROKEN) return;
983
+
984
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
985
+ sfuClient.isFastReconnecting = this.reconnectAttempts === 0;
986
+ const strategy = sfuClient.isFastReconnecting ? 'fast' : 'full';
987
+ reconnect(strategy, `SFU closed the WS with code: ${e.code}`).catch(
988
+ (err) => {
989
+ this.logger(
990
+ 'error',
991
+ `[Rejoin]: ${strategy} rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
992
+ err,
993
+ );
994
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
995
+ },
996
+ );
997
+ } else {
998
+ this.logger(
999
+ 'error',
1000
+ '[Rejoin]: Reconnect attempts exceeded. Giving up...',
1001
+ );
1002
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
1003
+ }
1004
+ });
1005
+ });
1006
+
1007
+ // handlers for connection online/offline events
1008
+ const unsubscribeOnlineEvent = this.streamClient.on(
1009
+ 'connection.changed',
1010
+ async (e) => {
1011
+ if (e.type !== 'connection.changed') return;
1012
+ if (!e.online) return;
1013
+ unsubscribeOnlineEvent();
1014
+ const currentCallingState = this.state.callingState;
1015
+ const shouldReconnect =
1016
+ currentCallingState === CallingState.OFFLINE ||
1017
+ currentCallingState === CallingState.RECONNECTING_FAILED;
1018
+ if (!shouldReconnect) return;
1019
+ this.logger('info', '[Rejoin]: Going online...');
1020
+ let isFirstReconnectAttempt = true;
1021
+ do {
1022
+ try {
1023
+ sfuClient.isFastReconnecting = isFirstReconnectAttempt;
1024
+ await reconnect(
1025
+ isFirstReconnectAttempt ? 'fast' : 'full',
1026
+ 'Network: online',
1027
+ );
1028
+ return; // break the loop if rejoin is successful
1029
+ } catch (err) {
1030
+ this.logger(
1031
+ 'error',
1032
+ `[Rejoin][Network]: Rejoin failed for attempt ${this.reconnectAttempts}`,
1033
+ err,
1034
+ );
1035
+ }
1036
+ // wait for a bit before trying to reconnect again
1037
+ await sleep(retryInterval(this.reconnectAttempts));
1038
+ isFirstReconnectAttempt = false;
1039
+ } while (this.reconnectAttempts < this.maxReconnectAttempts);
1040
+
1041
+ // if we're here, it means that we've exhausted all the reconnect attempts
1042
+ this.logger('error', `[Rejoin][Network]: Rejoin failed. Giving up.`);
1043
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
1044
+ },
1045
+ );
1046
+ const unsubscribeOfflineEvent = this.streamClient.on(
1047
+ 'connection.changed',
1048
+ (e) => {
1049
+ if (e.type !== 'connection.changed') return;
1050
+ if (e.online) return;
1051
+ unsubscribeOfflineEvent();
1052
+ this.state.setCallingState(CallingState.OFFLINE);
960
1053
  },
1054
+ );
1055
+
1056
+ this.leaveCallHooks.add(() => {
1057
+ unsubscribeOnlineEvent();
1058
+ unsubscribeOfflineEvent();
961
1059
  });
962
1060
 
1061
+ if (!this.subscriber) {
1062
+ this.subscriber = new Subscriber({
1063
+ sfuClient,
1064
+ dispatcher: this.dispatcher,
1065
+ state: this.state,
1066
+ connectionConfig,
1067
+ onUnrecoverableError: () => {
1068
+ reconnect('full', 'unrecoverable subscriber error').catch((err) => {
1069
+ this.logger('debug', '[Rejoin]: Rejoin failed', err);
1070
+ });
1071
+ },
1072
+ });
1073
+ }
1074
+
963
1075
  // anonymous users can't publish anything hence, there is no need
964
1076
  // to create Publisher Peer Connection for them
965
1077
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
966
- if (!isAnonymous) {
967
- if (closePreviousInstances && this.publisher) {
968
- this.publisher.close({ stopTracks: false });
969
- }
1078
+ if (!this.publisher && !isAnonymous) {
970
1079
  const audioSettings = this.state.settings?.audio;
971
1080
  const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
972
1081
  const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
@@ -977,29 +1086,25 @@ export class Call {
977
1086
  connectionConfig,
978
1087
  isDtxEnabled,
979
1088
  isRedEnabled,
980
- logTag: String(this.reconnectAttempts),
981
1089
  onUnrecoverableError: () => {
982
- this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
983
- this.logger(
984
- 'warn',
985
- '[Reconnect] Error reconnecting after a publisher error',
986
- err,
987
- );
1090
+ reconnect('full', 'unrecoverable publisher error').catch((err) => {
1091
+ this.logger('debug', '[Rejoin]: Rejoin failed', err);
988
1092
  });
989
1093
  },
990
1094
  });
991
1095
  }
992
1096
 
993
- this.statsReporter?.stop();
994
- this.statsReporter = createStatsReporter({
995
- subscriber: this.subscriber,
996
- publisher: this.publisher,
997
- state: this.state,
998
- datacenter: sfuClient.edgeName,
999
- });
1097
+ if (!this.statsReporter) {
1098
+ this.statsReporter = createStatsReporter({
1099
+ subscriber: this.subscriber,
1100
+ publisher: this.publisher,
1101
+ state: this.state,
1102
+ datacenter: this.sfuClient.edgeName,
1103
+ });
1104
+ }
1000
1105
 
1001
- this.sfuStatsReporter?.stop();
1002
- if (statsOptions?.reporting_interval_ms > 0) {
1106
+ const clientDetails = getClientDetails();
1107
+ if (!this.sfuStatsReporter && statsOptions) {
1003
1108
  this.sfuStatsReporter = new SfuStatsReporter(sfuClient, {
1004
1109
  clientDetails,
1005
1110
  options: statsOptions,
@@ -1008,316 +1113,152 @@ export class Call {
1008
1113
  });
1009
1114
  this.sfuStatsReporter.start();
1010
1115
  }
1011
- };
1012
-
1013
- /**
1014
- * Retrieves credentials for joining the call.
1015
- *
1016
- * @internal
1017
- *
1018
- * @param data the join call data.
1019
- */
1020
- doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1021
- const location = await this.streamClient.getLocationHint();
1022
- const request: JoinCallRequest = { ...data, location };
1023
- const joinResponse = await this.streamClient.post<
1024
- JoinCallResponse,
1025
- JoinCallRequest
1026
- >(`${this.streamClientBasePath}/join`, request);
1027
- this.state.updateFromCallResponse(joinResponse.call);
1028
- this.state.setMembers(joinResponse.members);
1029
- this.state.setOwnCapabilities(joinResponse.own_capabilities);
1030
-
1031
- if (data?.ring && !this.ringing) {
1032
- this.ringingSubject.next(true);
1033
- }
1034
-
1035
- if (this.ringing && !this.isCreatedByMe) {
1036
- // signals other users that I have accepted the incoming call.
1037
- await this.accept();
1038
- }
1039
-
1040
- if (this.streamClient._hasConnectionID()) {
1041
- this.watching = true;
1042
- this.clientStore.registerCall(this);
1043
- }
1044
-
1045
- return joinResponse;
1046
- };
1047
1116
 
1048
- /**
1049
- * Handles the closing of the SFU signal connection.
1050
- *
1051
- * @internal
1052
- * @param sfuClient the SFU client instance that was closed.
1053
- */
1054
- private handleSfuSignalClose(sfuClient: StreamSfuClient) {
1055
- this.logger('debug', '[Reconnect] SFU signal connection closed');
1056
- // normal close, no need to reconnect
1057
- if (sfuClient.isLeaving) return;
1058
- this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
1059
- this.logger('warn', '[Reconnect] Error reconnecting', err);
1060
- });
1061
- }
1117
+ try {
1118
+ // 1. wait for the signal server to be ready before sending "joinRequest"
1119
+ sfuClient.signalReady
1120
+ .catch((err) => this.logger('error', 'Signal ready failed', err))
1121
+ // prepare a generic SDP and send it to the SFU.
1122
+ // this is a throw-away SDP that the SFU will use to determine
1123
+ // the capabilities of the client (codec support, etc.)
1124
+ .then(() => getGenericSdp('recvonly'))
1125
+ .then((sdp) => {
1126
+ const subscriptions = getCurrentValue(this.trackSubscriptionsSubject);
1127
+ const migration: Migration | undefined = isMigrating
1128
+ ? {
1129
+ fromSfuId: data?.migrating_from || '',
1130
+ subscriptions: subscriptions.data || [],
1131
+ announcedTracks: this.publisher?.getCurrentTrackInfos() || [],
1132
+ }
1133
+ : undefined;
1062
1134
 
1063
- /**
1064
- * Handles the reconnection flow.
1065
- *
1066
- * @internal
1067
- *
1068
- * @param strategy the reconnection strategy to use.
1069
- */
1070
- private reconnect = async (
1071
- strategy: WebsocketReconnectStrategy,
1072
- ): Promise<void> => {
1073
- return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1074
- this.logger(
1075
- 'info',
1076
- `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`,
1077
- );
1135
+ return sfuClient.join({
1136
+ subscriberSdp: sdp || '',
1137
+ clientDetails,
1138
+ migration,
1139
+ fastReconnect: previousSfuClient?.isFastReconnecting ?? false,
1140
+ });
1141
+ });
1078
1142
 
1079
- this.reconnectStrategy = strategy;
1080
- do {
1081
- const current = WebsocketReconnectStrategy[this.reconnectStrategy];
1082
- try {
1083
- // wait until the network is available
1084
- await this.networkAvailableTask?.promise;
1085
- switch (this.reconnectStrategy) {
1086
- case WebsocketReconnectStrategy.UNSPECIFIED:
1087
- case WebsocketReconnectStrategy.DISCONNECT:
1088
- this.logger('debug', `[Reconnect] No-op strategy ${current}`);
1089
- break;
1090
- case WebsocketReconnectStrategy.FAST:
1091
- await this.reconnectFast();
1092
- break;
1093
- case WebsocketReconnectStrategy.REJOIN:
1094
- await this.reconnectRejoin();
1095
- break;
1096
- case WebsocketReconnectStrategy.MIGRATE:
1097
- await this.reconnectMigrate();
1098
- break;
1099
- default:
1100
- ensureExhausted(
1101
- this.reconnectStrategy,
1102
- 'Unknown reconnection strategy',
1103
- );
1104
- break;
1143
+ // 2. in parallel, wait for the SFU to send us the "joinResponse"
1144
+ // this will throw an error if the SFU rejects the join request or
1145
+ // fails to respond in time
1146
+ const { callState, reconnected } = await this.waitForJoinResponse();
1147
+ if (isReconnecting) {
1148
+ this.logger('debug', '[Rejoin] fast reconnected:', reconnected);
1149
+ }
1150
+ if (isMigrating) {
1151
+ await this.subscriber.migrateTo(sfuClient, connectionConfig);
1152
+ await this.publisher?.migrateTo(sfuClient, connectionConfig);
1153
+ } else if (isReconnecting) {
1154
+ if (reconnected) {
1155
+ // update the SFU client instance on the subscriber and publisher
1156
+ this.subscriber.setSfuClient(sfuClient);
1157
+ // publisher might not be there (anonymous users)
1158
+ if (this.publisher) {
1159
+ this.publisher.setSfuClient(sfuClient);
1160
+ // and perform a full ICE restart on the publisher
1161
+ await this.publisher.restartIce();
1105
1162
  }
1106
- break; // do-while loop, reconnection worked, exit the loop
1107
- } catch (error) {
1108
- this.logger(
1109
- 'warn',
1110
- `[Reconnect] ${current}(${this.reconnectAttempts}) failed. Attempting with REJOIN`,
1111
- error,
1112
- );
1113
- await sleep(retryInterval(this.reconnectAttempts));
1114
- this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
1115
- this.reconnectAttempts++;
1163
+ } else if (previousSfuClient?.isFastReconnecting) {
1164
+ // reconnection wasn't possible, so we need to do a full rejoin
1165
+ return await reconnect('full', 're-attempting').catch((err) => {
1166
+ this.logger(
1167
+ 'error',
1168
+ `[Rejoin]: Rejoin failed forced full rejoin.`,
1169
+ err,
1170
+ );
1171
+ });
1116
1172
  }
1117
- } while (
1118
- this.state.callingState !== CallingState.JOINED &&
1119
- this.state.callingState !== CallingState.LEFT
1120
- );
1121
- });
1122
- };
1123
-
1124
- /**
1125
- * Initiates the reconnection flow with the "fast" strategy.
1126
- * @internal
1127
- */
1128
- private reconnectFast = async () => {
1129
- this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
1130
- this.state.setCallingState(CallingState.RECONNECTING);
1131
- return this.join(this.joinCallData);
1132
- };
1133
-
1134
- /**
1135
- * Initiates the reconnection flow with the "rejoin" strategy.
1136
- * @internal
1137
- */
1138
- private reconnectRejoin = async () => {
1139
- this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
1140
- this.state.setCallingState(CallingState.RECONNECTING);
1141
- await this.join(this.joinCallData);
1142
- await this.restorePublishedTracks();
1143
- this.restoreSubscribedTracks();
1144
- };
1145
-
1146
- /**
1147
- * Initiates the reconnection flow with the "migrate" strategy.
1148
- * @internal
1149
- */
1150
- private reconnectMigrate = async () => {
1151
- const currentSfuClient = this.sfuClient;
1152
- if (!currentSfuClient) {
1153
- throw new Error('Cannot migrate without an active SFU client');
1154
- }
1155
-
1156
- this.reconnectStrategy = WebsocketReconnectStrategy.MIGRATE;
1157
- this.state.setCallingState(CallingState.MIGRATING);
1158
- const currentSubscriber = this.subscriber;
1159
- const currentPublisher = this.publisher;
1160
-
1161
- currentSubscriber?.detachEventHandlers();
1162
- currentPublisher?.detachEventHandlers();
1173
+ }
1174
+ const currentParticipants = callState?.participants || [];
1175
+ const participantCount = callState?.participantCount;
1176
+ const startedAt = callState?.startedAt
1177
+ ? Timestamp.toDate(callState.startedAt)
1178
+ : new Date();
1179
+ const pins = callState?.pins ?? [];
1180
+ this.state.setParticipants(() => {
1181
+ const participantLookup = this.state.getParticipantLookupBySessionId();
1182
+ return currentParticipants.map<StreamVideoParticipant>((p) => {
1183
+ // We need to preserve the local state of the participant
1184
+ // (e.g. videoDimension, visibilityState, pinnedAt, etc.)
1185
+ // as it doesn't exist on the server.
1186
+ const existingParticipant = participantLookup[p.sessionId];
1187
+ return Object.assign(p, existingParticipant, {
1188
+ isLocalParticipant: p.sessionId === sfuClient.sessionId,
1189
+ viewportVisibilityState:
1190
+ existingParticipant?.viewportVisibilityState ?? {
1191
+ videoTrack: VisibilityState.UNKNOWN,
1192
+ screenShareTrack: VisibilityState.UNKNOWN,
1193
+ },
1194
+ } satisfies Partial<StreamVideoParticipant>);
1195
+ });
1196
+ });
1197
+ this.state.setParticipantCount(participantCount?.total || 0);
1198
+ this.state.setAnonymousParticipantCount(participantCount?.anonymous || 0);
1199
+ this.state.setStartedAt(startedAt);
1200
+ this.state.setServerSidePins(pins);
1163
1201
 
1164
- const migrationTask = currentSfuClient.enterMigration();
1202
+ this.reconnectAttempts = 0; // reset the reconnect attempts counter
1203
+ this.state.setCallingState(CallingState.JOINED);
1165
1204
 
1166
- try {
1167
- const currentSfu = currentSfuClient.edgeName;
1168
- await this.join({ ...this.joinCallData, migrating_from: currentSfu });
1169
- } finally {
1170
- // cleanup the migration_from field after the migration is complete or failed
1171
- // as we don't want to keep dirty data in the join call data
1172
- delete this.joinCallData?.migrating_from;
1173
- }
1205
+ try {
1206
+ await this.initCamera({ setStatus: true });
1207
+ await this.initMic({ setStatus: true });
1208
+ } catch (error) {
1209
+ this.logger(
1210
+ 'warn',
1211
+ 'Camera and/or mic init failed during join call',
1212
+ error,
1213
+ );
1214
+ }
1174
1215
 
1175
- await this.restorePublishedTracks();
1176
- this.restoreSubscribedTracks();
1216
+ // 3. once we have the "joinResponse", and possibly reconciled the local state
1217
+ // we schedule a fast subscription update for all remote participants
1218
+ // that were visible before we reconnected or migrated to a new SFU.
1219
+ const { remoteParticipants } = this.state;
1220
+ if (remoteParticipants.length > 0) {
1221
+ this.updateSubscriptions(remoteParticipants, DebounceType.FAST);
1222
+ }
1177
1223
 
1178
- try {
1179
- // Wait for the migration to complete, then close the previous SFU client
1180
- // and the peer connection instances. In case of failure, the migration
1181
- // task would throw an error and REJOIN would be attempted.
1182
- await migrationTask;
1183
- } finally {
1184
- currentSubscriber?.close();
1185
- currentPublisher?.close({ stopTracks: false });
1186
-
1187
- // and close the previous SFU client, without specifying close code
1188
- currentSfuClient.close();
1224
+ this.logger('info', `Joined call ${this.cid}`);
1225
+ } catch (err) {
1226
+ // join failed, try to rejoin
1227
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
1228
+ this.logger(
1229
+ 'error',
1230
+ `[Rejoin]: Rejoin ${this.reconnectAttempts} failed.`,
1231
+ err,
1232
+ );
1233
+ await reconnect('full', 'previous attempt failed');
1234
+ this.logger(
1235
+ 'info',
1236
+ `[Rejoin]: Rejoin ${this.reconnectAttempts} successful!`,
1237
+ );
1238
+ } else {
1239
+ this.logger(
1240
+ 'error',
1241
+ `[Rejoin]: Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
1242
+ );
1243
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
1244
+ throw new Error('Join failed');
1245
+ }
1189
1246
  }
1190
1247
  };
1191
1248
 
1192
- /**
1193
- * Registers the various event handlers for reconnection.
1194
- *
1195
- * @internal
1196
- */
1197
- private registerReconnectHandlers = () => {
1198
- // handles the legacy "goAway" event
1199
- const unregisterGoAway = this.on('goAway', () => {
1200
- this.reconnect(WebsocketReconnectStrategy.MIGRATE).catch((err) => {
1201
- this.logger('warn', '[Reconnect] Error reconnecting', err);
1249
+ private waitForJoinResponse = (timeout: number = 5000) => {
1250
+ return new Promise<JoinResponse>((resolve, reject) => {
1251
+ const unsubscribe = this.on('joinResponse', (event) => {
1252
+ clearTimeout(timeoutId);
1253
+ unsubscribe();
1254
+ resolve(event);
1202
1255
  });
1203
- });
1204
1256
 
1205
- // handles the "error" event, through which the SFU can request a reconnect
1206
- const unregisterOnError = this.on('error', (e) => {
1207
- const { reconnectStrategy: strategy } = e;
1208
- if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
1209
- if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1210
- this.leave({ reason: 'SFU instructed to disconnect' }).catch((err) => {
1211
- this.logger('warn', `Can't leave call after disconnect request`, err);
1212
- });
1213
- } else {
1214
- this.reconnect(strategy).catch((err) => {
1215
- this.logger('warn', '[Reconnect] Error reconnecting', err);
1216
- });
1217
- }
1257
+ const timeoutId = setTimeout(() => {
1258
+ unsubscribe();
1259
+ reject(new Error('Waiting for "joinResponse" has timed out'));
1260
+ }, timeout);
1218
1261
  });
1219
-
1220
- const unregisterNetworkChanged = this.streamClient.on(
1221
- 'network.changed',
1222
- (e) => {
1223
- if (!e.online) {
1224
- this.logger('debug', '[Reconnect] Going offline');
1225
- if (!this.hasJoinedOnce) return;
1226
- this.lastOfflineTimestamp = Date.now();
1227
- // create a new task that would resolve when the network is available
1228
- const networkAvailableTask = promiseWithResolvers();
1229
- networkAvailableTask.promise.then(() => {
1230
- let strategy = WebsocketReconnectStrategy.FAST;
1231
- if (this.lastOfflineTimestamp) {
1232
- const offline = (Date.now() - this.lastOfflineTimestamp) / 1000;
1233
- if (offline > this.fastReconnectDeadlineSeconds) {
1234
- // We shouldn't attempt FAST if we have exceeded the deadline.
1235
- // The SFU would have already wiped out the session.
1236
- strategy = WebsocketReconnectStrategy.REJOIN;
1237
- }
1238
- }
1239
-
1240
- this.reconnect(strategy).catch((err) => {
1241
- this.logger(
1242
- 'warn',
1243
- '[Reconnect] Error restoring connection after going online',
1244
- err,
1245
- );
1246
- });
1247
- });
1248
- this.networkAvailableTask = networkAvailableTask;
1249
- this.sfuStatsReporter?.stop();
1250
- this.state.setCallingState(CallingState.OFFLINE);
1251
- } else {
1252
- this.logger('debug', '[Reconnect] Going online');
1253
- // TODO try to remove this .close call
1254
- this.sfuClient?.close(
1255
- 4002,
1256
- 'Closing WS to reconnect after going online',
1257
- );
1258
- // we went online, release the previous waiters and reset the state
1259
- this.networkAvailableTask?.resolve();
1260
- this.networkAvailableTask = undefined;
1261
- this.sfuStatsReporter?.start();
1262
- }
1263
- },
1264
- );
1265
-
1266
- this.leaveCallHooks.add(unregisterGoAway);
1267
- this.leaveCallHooks.add(unregisterOnError);
1268
- this.leaveCallHooks.add(unregisterNetworkChanged);
1269
- };
1270
-
1271
- /**
1272
- * Restores the published tracks after a reconnection.
1273
- * @internal
1274
- */
1275
- private restorePublishedTracks = async () => {
1276
- // the tracks need to be restored in their original order of publishing
1277
- // otherwise, we might get `m-lines order mismatch` errors
1278
- for (const trackType of this.trackPublishOrder) {
1279
- switch (trackType) {
1280
- case TrackType.AUDIO:
1281
- const audioStream = this.microphone.state.mediaStream;
1282
- if (audioStream) {
1283
- await this.publishAudioStream(audioStream);
1284
- }
1285
- break;
1286
- case TrackType.VIDEO:
1287
- const videoStream = this.camera.state.mediaStream;
1288
- if (videoStream) {
1289
- await this.publishVideoStream(videoStream, {
1290
- preferredCodec: this.camera.preferredCodec,
1291
- });
1292
- }
1293
- break;
1294
- case TrackType.SCREEN_SHARE:
1295
- const screenShareStream = this.screenShare.state.mediaStream;
1296
- if (screenShareStream) {
1297
- await this.publishScreenShareStream(screenShareStream, {
1298
- screenShareSettings: this.screenShare.getSettings(),
1299
- });
1300
- }
1301
- break;
1302
- // screen share audio can't exist without a screen share, so we handle it there
1303
- case TrackType.SCREEN_SHARE_AUDIO:
1304
- case TrackType.UNSPECIFIED:
1305
- break;
1306
- default:
1307
- ensureExhausted(trackType, 'Unknown track type');
1308
- break;
1309
- }
1310
- }
1311
- };
1312
-
1313
- /**
1314
- * Restores the subscribed tracks after a reconnection.
1315
- * @internal
1316
- */
1317
- private restoreSubscribedTracks = () => {
1318
- const { remoteParticipants } = this.state;
1319
- if (remoteParticipants.length <= 0) return;
1320
- this.updateSubscriptions(remoteParticipants, DebounceType.FAST);
1321
1262
  };
1322
1263
 
1323
1264
  /**
@@ -1336,7 +1277,7 @@ export class Call {
1336
1277
  ) => {
1337
1278
  // we should wait until we get a JoinResponse from the SFU,
1338
1279
  // otherwise we risk breaking the ICETrickle flow.
1339
- await this.waitUntilCallJoined();
1280
+ await this.assertCallJoined();
1340
1281
  if (!this.publisher) {
1341
1282
  this.logger('error', 'Trying to publish video before join is completed');
1342
1283
  throw new Error(`Call not joined yet.`);
@@ -1348,9 +1289,6 @@ export class Call {
1348
1289
  return;
1349
1290
  }
1350
1291
 
1351
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
1352
- this.trackPublishOrder.push(TrackType.VIDEO);
1353
- }
1354
1292
  await this.publisher.publishStream(
1355
1293
  videoStream,
1356
1294
  videoTrack,
@@ -1371,7 +1309,7 @@ export class Call {
1371
1309
  publishAudioStream = async (audioStream: MediaStream) => {
1372
1310
  // we should wait until we get a JoinResponse from the SFU,
1373
1311
  // otherwise we risk breaking the ICETrickle flow.
1374
- await this.waitUntilCallJoined();
1312
+ await this.assertCallJoined();
1375
1313
  if (!this.publisher) {
1376
1314
  this.logger('error', 'Trying to publish audio before join is completed');
1377
1315
  throw new Error(`Call not joined yet.`);
@@ -1383,9 +1321,6 @@ export class Call {
1383
1321
  return;
1384
1322
  }
1385
1323
 
1386
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
1387
- this.trackPublishOrder.push(TrackType.AUDIO);
1388
- }
1389
1324
  await this.publisher.publishStream(
1390
1325
  audioStream,
1391
1326
  audioTrack,
@@ -1408,7 +1343,7 @@ export class Call {
1408
1343
  ) => {
1409
1344
  // we should wait until we get a JoinResponse from the SFU,
1410
1345
  // otherwise we risk breaking the ICETrickle flow.
1411
- await this.waitUntilCallJoined();
1346
+ await this.assertCallJoined();
1412
1347
  if (!this.publisher) {
1413
1348
  this.logger(
1414
1349
  'error',
@@ -1426,9 +1361,6 @@ export class Call {
1426
1361
  return;
1427
1362
  }
1428
1363
 
1429
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
1430
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
1431
- }
1432
1364
  await this.publisher.publishStream(
1433
1365
  screenShareStream,
1434
1366
  screenShareTrack,
@@ -1438,9 +1370,6 @@ export class Call {
1438
1370
 
1439
1371
  const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
1440
1372
  if (screenShareAudioTrack) {
1441
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
1442
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
1443
- }
1444
1373
  await this.publisher.publishStream(
1445
1374
  screenShareStream,
1446
1375
  screenShareAudioTrack,
@@ -1497,15 +1426,31 @@ export class Call {
1497
1426
  * @param type the debounce type to use for the update.
1498
1427
  */
1499
1428
  updateSubscriptionsPartial = (
1500
- trackType: VideoTrackType,
1429
+ trackType: VideoTrackType | 'video' | 'screen',
1501
1430
  changes: SubscriptionChanges,
1502
1431
  type: DebounceType = DebounceType.SLOW,
1503
1432
  ) => {
1433
+ if (trackType === 'video') {
1434
+ this.logger(
1435
+ 'warn',
1436
+ `updateSubscriptionsPartial: ${trackType} is deprecated. Please switch to 'videoTrack'`,
1437
+ );
1438
+ trackType = 'videoTrack';
1439
+ } else if (trackType === 'screen') {
1440
+ this.logger(
1441
+ 'warn',
1442
+ `updateSubscriptionsPartial: ${trackType} is deprecated. Please switch to 'screenShareTrack'`,
1443
+ );
1444
+ trackType = 'screenShareTrack';
1445
+ }
1446
+
1504
1447
  const participants = this.state.updateParticipants(
1505
1448
  Object.entries(changes).reduce<StreamVideoParticipantPatches>(
1506
1449
  (acc, [sessionId, change]) => {
1507
- if (change.dimension) {
1450
+ if (change.dimension?.height) {
1508
1451
  change.dimension.height = Math.ceil(change.dimension.height);
1452
+ }
1453
+ if (change.dimension?.width) {
1509
1454
  change.dimension.width = Math.ceil(change.dimension.width);
1510
1455
  }
1511
1456
  const prop: keyof StreamVideoParticipant | undefined =
@@ -1525,7 +1470,9 @@ export class Call {
1525
1470
  ),
1526
1471
  );
1527
1472
 
1528
- this.updateSubscriptions(participants, type);
1473
+ if (participants) {
1474
+ this.updateSubscriptions(participants, type);
1475
+ }
1529
1476
  };
1530
1477
 
1531
1478
  private updateSubscriptions = (
@@ -1618,12 +1565,12 @@ export class Call {
1618
1565
  return this.publisher?.updateVideoPublishQuality(enabledLayers);
1619
1566
  };
1620
1567
 
1621
- private waitUntilCallJoined = () => {
1568
+ private assertCallJoined = () => {
1622
1569
  return new Promise<void>((resolve) => {
1623
1570
  this.state.callingState$
1624
1571
  .pipe(
1625
1572
  takeWhile((state) => state !== CallingState.JOINED, true),
1626
- filter((state) => state === CallingState.JOINED),
1573
+ filter((s) => s === CallingState.JOINED),
1627
1574
  )
1628
1575
  .subscribe(() => resolve());
1629
1576
  });
@@ -2145,16 +2092,16 @@ export class Call {
2145
2092
  *
2146
2093
  * @internal
2147
2094
  */
2148
- applyDeviceConfig = async (status: boolean) => {
2149
- await this.initCamera({ setStatus: status }).catch((err) => {
2095
+ applyDeviceConfig = async () => {
2096
+ await this.initCamera({ setStatus: false }).catch((err) => {
2150
2097
  this.logger('warn', 'Camera init failed', err);
2151
2098
  });
2152
- await this.initMic({ setStatus: status }).catch((err) => {
2099
+ await this.initMic({ setStatus: false }).catch((err) => {
2153
2100
  this.logger('warn', 'Mic init failed', err);
2154
2101
  });
2155
2102
  };
2156
2103
 
2157
- private initCamera = async (options: { setStatus: boolean }) => {
2104
+ private async initCamera(options: { setStatus: boolean }) {
2158
2105
  // Wait for any in progress camera operation
2159
2106
  await this.camera.statusChangeSettled();
2160
2107
 
@@ -2184,7 +2131,7 @@ export class Call {
2184
2131
  if (options.setStatus) {
2185
2132
  // Publish already that was set before we joined
2186
2133
  if (
2187
- this.camera.enabled &&
2134
+ this.camera.state.status === 'enabled' &&
2188
2135
  this.camera.state.mediaStream &&
2189
2136
  !this.publisher?.isPublishing(TrackType.VIDEO)
2190
2137
  ) {
@@ -2201,9 +2148,9 @@ export class Call {
2201
2148
  await this.camera.enable();
2202
2149
  }
2203
2150
  }
2204
- };
2151
+ }
2205
2152
 
2206
- private initMic = async (options: { setStatus: boolean }) => {
2153
+ private async initMic(options: { setStatus: boolean }) {
2207
2154
  // Wait for any in progress mic operation
2208
2155
  await this.microphone.statusChangeSettled();
2209
2156
 
@@ -2217,7 +2164,7 @@ export class Call {
2217
2164
  if (options.setStatus) {
2218
2165
  // Publish media stream that was set before we joined
2219
2166
  if (
2220
- this.microphone.enabled &&
2167
+ this.microphone.state.status === 'enabled' &&
2221
2168
  this.microphone.state.mediaStream &&
2222
2169
  !this.publisher?.isPublishing(TrackType.AUDIO)
2223
2170
  ) {
@@ -2232,7 +2179,7 @@ export class Call {
2232
2179
  await this.microphone.enable();
2233
2180
  }
2234
2181
  }
2235
- };
2182
+ }
2236
2183
 
2237
2184
  /**
2238
2185
  * Will begin tracking the given element for visibility changes within the