@stream-io/video-client 0.0.27 → 0.0.29

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 (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +2517 -1760
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +2537 -1758
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +2517 -1760
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +7 -8
  9. package/dist/src/StreamSfuClient.d.ts +23 -10
  10. package/dist/src/StreamVideoClient.d.ts +1 -4
  11. package/dist/src/client-details.d.ts +2 -1
  12. package/dist/src/coordinator/connection/types.d.ts +2 -2
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/events/internal.d.ts +4 -0
  15. package/dist/src/gen/coordinator/index.d.ts +6 -0
  16. package/dist/src/gen/google/protobuf/struct.d.ts +8 -15
  17. package/dist/src/gen/google/protobuf/timestamp.d.ts +2 -9
  18. package/dist/src/gen/video/sfu/event/events.d.ts +121 -1
  19. package/dist/src/gen/video/sfu/models/models.d.ts +38 -1
  20. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +3 -14
  21. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +4 -12
  22. package/dist/src/logger.d.ts +4 -2
  23. package/dist/src/rtc/Dispatcher.d.ts +1 -2
  24. package/dist/src/rtc/{publisher.d.ts → Publisher.d.ts} +49 -15
  25. package/dist/src/rtc/Subscriber.d.ts +58 -0
  26. package/dist/src/rtc/__tests__/Subscriber.test.d.ts +1 -0
  27. package/dist/src/rtc/flows/join.d.ts +8 -1
  28. package/dist/src/rtc/index.d.ts +2 -2
  29. package/dist/src/rtc/signal.d.ts +1 -0
  30. package/dist/src/stats/state-store-stats-reporter.d.ts +3 -4
  31. package/dist/src/store/CallState.d.ts +10 -0
  32. package/package.json +3 -1
  33. package/src/Call.ts +218 -214
  34. package/src/StreamSfuClient.ts +48 -21
  35. package/src/StreamVideoClient.ts +7 -24
  36. package/src/client-details.ts +33 -1
  37. package/src/coordinator/connection/client.ts +6 -8
  38. package/src/coordinator/connection/types.ts +2 -3
  39. package/src/coordinator/connection/utils.ts +1 -0
  40. package/src/events/call.ts +0 -1
  41. package/src/events/callEventHandlers.ts +2 -0
  42. package/src/events/internal.ts +20 -0
  43. package/src/events/sessions.ts +0 -1
  44. package/src/gen/coordinator/index.ts +6 -0
  45. package/src/gen/google/protobuf/struct.ts +541 -333
  46. package/src/gen/google/protobuf/timestamp.ts +214 -148
  47. package/src/gen/video/sfu/event/events.ts +353 -3
  48. package/src/gen/video/sfu/models/models.ts +37 -0
  49. package/src/gen/video/sfu/signal_rpc/signal.client.ts +160 -94
  50. package/src/gen/video/sfu/signal_rpc/signal.ts +1214 -731
  51. package/src/logger.ts +43 -30
  52. package/src/rtc/Dispatcher.ts +5 -9
  53. package/src/rtc/{publisher.ts → Publisher.ts} +245 -111
  54. package/src/rtc/Subscriber.ts +304 -0
  55. package/src/rtc/__tests__/{publisher.test.ts → Publisher.test.ts} +77 -9
  56. package/src/rtc/__tests__/Subscriber.test.ts +121 -0
  57. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +20 -0
  58. package/src/rtc/flows/join.ts +43 -2
  59. package/src/rtc/index.ts +2 -2
  60. package/src/rtc/signal.ts +6 -5
  61. package/src/rtc/videoLayers.ts +1 -4
  62. package/src/stats/state-store-stats-reporter.ts +3 -5
  63. package/src/store/CallState.ts +20 -0
  64. package/src/types.ts +0 -1
  65. package/dist/src/rtc/subscriber.d.ts +0 -9
  66. package/src/rtc/subscriber.ts +0 -107
  67. /package/dist/src/rtc/__tests__/{publisher.test.d.ts → Publisher.test.d.ts} +0 -0
package/src/Call.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { StreamSfuClient } from './StreamSfuClient';
2
2
  import {
3
- createSubscriber,
4
3
  Dispatcher,
5
4
  getGenericSdp,
6
5
  isSfuEvent,
7
6
  Publisher,
8
7
  SfuEventKinds,
9
8
  SfuEventListener,
9
+ Subscriber,
10
10
  } from './rtc';
11
11
  import { muteTypeToTrackType } from './rtc/helpers/tracks';
12
- import { ClientDetails, TrackType } from './gen/video/sfu/models/models';
12
+ import { GoAwayReason, TrackType } from './gen/video/sfu/models/models';
13
13
  import {
14
14
  registerEventHandlers,
15
15
  registerRingingCallEventHandlers,
@@ -57,7 +57,7 @@ import {
57
57
  UpdateUserPermissionsRequest,
58
58
  UpdateUserPermissionsResponse,
59
59
  } from './gen/coordinator';
60
- import { join } from './rtc/flows/join';
60
+ import { join, reconcileParticipantLocalState } from './rtc/flows/join';
61
61
  import {
62
62
  CallConstructor,
63
63
  CallLeaveOptions,
@@ -73,7 +73,6 @@ import {
73
73
  BehaviorSubject,
74
74
  debounce,
75
75
  map,
76
- of,
77
76
  pairwise,
78
77
  Subject,
79
78
  takeWhile,
@@ -81,7 +80,7 @@ import {
81
80
  timer,
82
81
  } from 'rxjs';
83
82
  import { TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
84
- import { JoinResponse } from './gen/video/sfu/event/events';
83
+ import { JoinResponse, Migration } from './gen/video/sfu/event/events';
85
84
  import { Timestamp } from './gen/google/protobuf/timestamp';
86
85
  import {
87
86
  createStatsReporter,
@@ -103,8 +102,7 @@ import {
103
102
  Logger,
104
103
  StreamCallEvent,
105
104
  } from './coordinator/connection/types';
106
- import { UAParser } from 'ua-parser-js';
107
- import { getDeviceInfo, getOSInfo, getSdkInfo } from './client-details';
105
+ import { getClientDetails } from './client-details';
108
106
  import { isReactNative } from './helpers/platforms';
109
107
  import { getLogger } from './logger';
110
108
 
@@ -170,12 +168,12 @@ export class Call {
170
168
  */
171
169
  private readonly dispatcher = new Dispatcher();
172
170
 
173
- private subscriber?: RTCPeerConnection;
171
+ private subscriber?: Subscriber;
174
172
  private publisher?: Publisher;
175
- private trackSubscriptionsSubject = new Subject<{
176
- type?: DebounceType;
173
+ private trackSubscriptionsSubject = new BehaviorSubject<{
174
+ type: DebounceType;
177
175
  data: TrackSubscriptionDetails[];
178
- }>();
176
+ }>({ type: DebounceType.MEDIUM, data: [] });
179
177
 
180
178
  private statsReporter?: StatsReporter;
181
179
  private dropTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -223,7 +221,7 @@ export class Call {
223
221
  this.streamClient = streamClient;
224
222
  this.clientStore = clientStore;
225
223
  this.streamClientBasePath = `/call/${this.type}/${this.id}`;
226
- this.logger = getLogger(['call']);
224
+ this.logger = getLogger(['Call']);
227
225
 
228
226
  const callTypeConfig = CallTypes.get(type);
229
227
  const participantSorter =
@@ -247,7 +245,7 @@ export class Call {
247
245
  this.leaveCallHooks.push(
248
246
  createSubscription(
249
247
  this.trackSubscriptionsSubject.pipe(
250
- debounce((v) => (!v.type ? of(null) : timer(v.type))),
248
+ debounce((v) => timer(v.type)),
251
249
  map((v) => v.data),
252
250
  ),
253
251
  (subscriptions) => this.sfuClient?.updateSubscriptions(subscriptions),
@@ -410,7 +408,7 @@ export class Call {
410
408
  this.subscriber?.close();
411
409
  this.subscriber = undefined;
412
410
 
413
- this.publisher?.stopPublishing();
411
+ this.publisher?.close();
414
412
  this.publisher = undefined;
415
413
 
416
414
  this.sfuClient?.close();
@@ -453,21 +451,6 @@ export class Call {
453
451
  return this.state.metadata?.created_by.id === this.currentUserId;
454
452
  }
455
453
 
456
- private waitForJoinResponse = (timeout: number = 5000) =>
457
- new Promise<JoinResponse>((resolve, reject) => {
458
- const unsubscribe = this.on('joinResponse', (event) => {
459
- if (event.eventPayload.oneofKind !== 'joinResponse') return;
460
- clearTimeout(timeoutId);
461
- unsubscribe();
462
- resolve(event.eventPayload.joinResponse);
463
- });
464
-
465
- const timeoutId = setTimeout(() => {
466
- unsubscribe();
467
- reject(new Error('Waiting for "joinResponse" has timed out'));
468
- }, timeout);
469
- });
470
-
471
454
  /**
472
455
  * Loads the information about the call.
473
456
  *
@@ -576,11 +559,8 @@ export class Call {
576
559
  * @returns a promise which resolves once the call join-flow has finished.
577
560
  */
578
561
  join = async (data?: JoinCallData) => {
579
- if (
580
- [CallingState.JOINED, CallingState.JOINING].includes(
581
- this.state.callingState,
582
- )
583
- ) {
562
+ const callingState = this.state.callingState;
563
+ if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
584
564
  this.logger(
585
565
  'warn',
586
566
  'Join method called twice, you should only call this once',
@@ -588,13 +568,13 @@ export class Call {
588
568
  throw new Error(`Illegal State: Already joined.`);
589
569
  }
590
570
 
591
- if (this.state.callingState === CallingState.LEFT) {
571
+ if (callingState === CallingState.LEFT) {
592
572
  throw new Error(
593
573
  'Illegal State: Cannot join already left call. Create a new Call instance to join a call.',
594
574
  );
595
575
  }
596
576
 
597
- const previousCallingState = this.state.callingState;
577
+ const isMigrating = callingState === CallingState.MIGRATING;
598
578
  this.state.setCallingState(CallingState.JOINING);
599
579
  this.logger('debug', 'Starting join flow');
600
580
 
@@ -625,58 +605,87 @@ export class Call {
625
605
  }
626
606
  } catch (error) {
627
607
  // restore the previous call state if the join-flow fails
628
- this.state.setCallingState(previousCallingState);
608
+ this.state.setCallingState(callingState);
629
609
  throw error;
630
610
  }
631
611
 
632
612
  // FIXME OL: remove once cascading is implemented
633
- let sfuUrl = sfuServer.url;
634
- let sfuWsUrl = sfuServer.ws_endpoint;
635
- if (
636
- typeof window !== 'undefined' &&
637
- window.location &&
638
- window.location.search
639
- ) {
613
+ if (typeof window !== 'undefined' && window.location.search) {
640
614
  const params = new URLSearchParams(window.location.search);
641
- const sfuUrlParam = params.get('sfuUrl');
642
- sfuUrl = sfuUrlParam || sfuServer.url;
643
- const sfuWsUrlParam = params.get('sfuWsUrl');
644
- sfuWsUrl = sfuWsUrlParam || sfuServer.ws_endpoint;
615
+ sfuServer.url = params.get('sfuUrl') || sfuServer.url;
616
+ sfuServer.ws_endpoint = params.get('sfuWsUrl') || sfuServer.ws_endpoint;
617
+ sfuServer.edge_name = params.get('sfuUrl') || sfuServer.edge_name;
645
618
  }
646
619
 
647
- const previousSessionId = this.sfuClient?.sessionId;
620
+ const previousSfuClient = this.sfuClient;
648
621
  const sfuClient = (this.sfuClient = new StreamSfuClient({
649
622
  dispatcher: this.dispatcher,
650
- url: sfuUrl,
651
- wsEndpoint: sfuWsUrl,
623
+ sfuServer,
652
624
  token: sfuToken,
653
- sessionId: previousSessionId,
625
+ sessionId: previousSfuClient?.sessionId,
654
626
  }));
655
627
 
656
628
  /**
657
629
  * A closure which hides away the re-connection logic.
658
630
  */
659
- const rejoin = async () => {
660
- this.logger(
661
- 'debug',
662
- `Rejoining call ${this.cid} (${this.reconnectAttempts})...`,
663
- );
631
+ const rejoin = async ({ migrate = false } = {}) => {
664
632
  this.reconnectAttempts++;
665
- this.state.setCallingState(CallingState.RECONNECTING);
633
+ this.state.setCallingState(
634
+ migrate ? CallingState.MIGRATING : CallingState.RECONNECTING,
635
+ );
636
+
637
+ if (migrate) {
638
+ this.logger(
639
+ 'debug',
640
+ `[Migration]: migrating call ${this.cid} away from ${sfuServer.edge_name}`,
641
+ );
642
+ sfuClient.isMigratingAway = true;
643
+ } else {
644
+ this.logger(
645
+ 'debug',
646
+ `[Rejoin]: Rejoining call ${this.cid} (${this.reconnectAttempts})...`,
647
+ );
648
+ }
666
649
 
667
650
  // take a snapshot of the current "local participant" state
668
651
  // we'll need it for restoring the previous publishing state later
669
652
  const localParticipant = this.state.localParticipant;
670
653
 
671
- this.subscriber?.close();
672
- this.publisher?.stopPublishing({ stopTracks: false });
673
- this.statsReporter?.stop();
674
- sfuClient.close(); // clean up previous connection
654
+ const disconnectFromPreviousSfu = () => {
655
+ if (!migrate) {
656
+ this.subscriber?.close();
657
+ this.subscriber = undefined;
658
+ this.publisher?.close({ stopTracks: false });
659
+ this.publisher = undefined;
660
+ this.statsReporter?.stop();
661
+ this.statsReporter = undefined;
662
+ }
663
+ previousSfuClient?.close(); // clean up previous connection
664
+ };
665
+
666
+ if (!migrate) {
667
+ // in migration or recovery scenarios, we don't want to
668
+ // wait before attempting to reconnect to an SFU server
669
+ await sleep(retryInterval(this.reconnectAttempts));
670
+ disconnectFromPreviousSfu();
671
+ }
672
+ await this.join({
673
+ ...data,
674
+ ...(migrate && { migrating_from: sfuServer.edge_name }),
675
+ });
675
676
 
676
- await sleep(retryInterval(this.reconnectAttempts));
677
- await this.join(data);
678
- this.logger('info', `Rejoin: ${this.reconnectAttempts} successful!`);
679
- if (localParticipant && !isReactNative()) {
677
+ if (migrate) {
678
+ disconnectFromPreviousSfu();
679
+ }
680
+
681
+ this.logger(
682
+ 'info',
683
+ `[Rejoin]: Attempt ${this.reconnectAttempts} successful!`,
684
+ );
685
+ // we shouldn't be republishing the streams if we're migrating
686
+ // as the underlying peer connection will take care of it as part
687
+ // of the ice-restart process
688
+ if (localParticipant && !isReactNative() && !migrate) {
680
689
  const {
681
690
  audioStream,
682
691
  videoStream,
@@ -688,7 +697,10 @@ export class Call {
688
697
  if (videoStream) await this.publishVideoStream(videoStream);
689
698
  if (screenShare) await this.publishScreenShareStream(screenShare);
690
699
  }
691
- this.logger('info', `Rejoin: state restored ${this.reconnectAttempts}`);
700
+ this.logger(
701
+ 'info',
702
+ `[Rejoin]: State restored. Attempt: ${this.reconnectAttempts}`,
703
+ );
692
704
  };
693
705
 
694
706
  this.rejoinPromise = rejoin;
@@ -697,24 +709,57 @@ export class Call {
697
709
  // - SFU crash or restart
698
710
  // - network change
699
711
  sfuClient.signalReady.then(() => {
712
+ // register a handler for the "goAway" event
713
+ const unregisterGoAway = this.dispatcher.on('goAway', (event) => {
714
+ if (event.eventPayload.oneofKind !== 'goAway') return;
715
+ const { reason } = event.eventPayload.goAway;
716
+ this.logger(
717
+ 'info',
718
+ `[Migration]: Going away from SFU... Reason: ${GoAwayReason[reason]}`,
719
+ );
720
+ rejoin({ migrate: true }).catch((err) => {
721
+ this.logger(
722
+ 'warn',
723
+ `[Migration]: Failed to migrate to another SFU.`,
724
+ err,
725
+ );
726
+ });
727
+ });
728
+
700
729
  sfuClient.signalWs.addEventListener('close', (e) => {
730
+ // unregister the "goAway" handler, as we won't need it anymore for this connection.
731
+ // the upcoming re-join will register a new handler anyway
732
+ unregisterGoAway();
701
733
  // do nothing if the connection was closed on purpose
702
734
  if (e.code === KnownCodes.WS_CLOSED_SUCCESS) return;
703
735
  // do nothing if the connection was closed because of a policy violation
704
736
  // e.g., the user has been blocked by an admin or moderator
705
737
  if (e.code === KnownCodes.WS_POLICY_VIOLATION) return;
706
- // do nothing for react-native as its handled by SDK
738
+ // When the SFU is being shut down, it sends a goAway message.
739
+ // While we migrate to another SFU, we might have the WS connection
740
+ // to the old SFU closed abruptly. In this case, we don't want
741
+ // to reconnect to the old SFU, but rather to the new one.
742
+ if (
743
+ e.code === KnownCodes.WS_CLOSED_ABRUPTLY &&
744
+ sfuClient.isMigratingAway
745
+ )
746
+ return;
747
+ // do nothing for react-native as it is handled by SDK
707
748
  if (isReactNative()) return;
708
749
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
709
- rejoin().catch(() => {
750
+ rejoin().catch((err) => {
710
751
  this.logger(
711
752
  'error',
712
- `Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
753
+ `[Rejoin]: Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
754
+ err,
713
755
  );
714
756
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
715
757
  });
716
758
  } else {
717
- this.logger('error', 'Reconnect attempts exceeded. Giving up...');
759
+ this.logger(
760
+ 'error',
761
+ '[Rejoin]: Reconnect attempts exceeded. Giving up...',
762
+ );
718
763
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
719
764
  }
720
765
  });
@@ -725,18 +770,19 @@ export class Call {
725
770
  if (typeof window !== 'undefined' && window.addEventListener) {
726
771
  const handleOnOffline = () => {
727
772
  window.removeEventListener('offline', handleOnOffline);
728
- this.logger('warn', 'Join: Going offline...');
773
+ this.logger('warn', '[Rejoin]: Going offline...');
729
774
  this.state.setCallingState(CallingState.OFFLINE);
730
775
  };
731
776
 
732
777
  const handleOnOnline = () => {
733
778
  window.removeEventListener('online', handleOnOnline);
734
779
  if (this.state.callingState === CallingState.OFFLINE) {
735
- this.logger('info', 'Join: Going online...');
736
- rejoin().catch(() => {
780
+ this.logger('info', '[Rejoin]: Going online...');
781
+ rejoin().catch((err) => {
737
782
  this.logger(
738
783
  'error',
739
- `Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
784
+ `[Rejoin]: Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
785
+ err,
740
786
  );
741
787
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
742
788
  });
@@ -753,57 +799,39 @@ export class Call {
753
799
  );
754
800
  }
755
801
 
756
- this.subscriber = createSubscriber({
757
- sfuClient,
758
- dispatcher: this.dispatcher,
759
- connectionConfig,
760
- onTrack: this.handleOnTrack,
761
- });
802
+ if (!this.subscriber) {
803
+ this.subscriber = new Subscriber({
804
+ sfuClient,
805
+ dispatcher: this.dispatcher,
806
+ state: this.state,
807
+ connectionConfig,
808
+ });
809
+ }
762
810
 
763
811
  const audioSettings = this.data?.settings.audio;
764
812
  const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
765
813
  const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
766
- this.publisher = new Publisher({
767
- sfuClient,
768
- state: this.state,
769
- connectionConfig,
770
- isDtxEnabled,
771
- isRedEnabled,
772
- preferredVideoCodec: this.streamClient.options.preferredVideoCodec,
773
- });
774
814
 
775
- this.statsReporter = createStatsReporter({
776
- subscriber: this.subscriber,
777
- publisher: this.publisher,
778
- state: this.state,
779
- edgeName: sfuServer.edge_name,
780
- });
815
+ if (!this.publisher) {
816
+ this.publisher = new Publisher({
817
+ sfuClient,
818
+ state: this.state,
819
+ connectionConfig,
820
+ isDtxEnabled,
821
+ isRedEnabled,
822
+ preferredVideoCodec: this.streamClient.options.preferredVideoCodec,
823
+ });
824
+ }
825
+
826
+ if (!isMigrating) {
827
+ this.statsReporter = createStatsReporter({
828
+ subscriber: this.subscriber,
829
+ publisher: this.publisher,
830
+ state: this.state,
831
+ });
832
+ }
781
833
 
782
834
  try {
783
- const clientDetails: ClientDetails = {};
784
- if (isReactNative()) {
785
- // Since RN doesn't support web, sharing browser info is not required
786
- clientDetails.os = getOSInfo();
787
- clientDetails.device = getDeviceInfo();
788
- } else {
789
- const details = new UAParser(navigator.userAgent).getResult();
790
- clientDetails.browser = {
791
- name: details.browser.name || navigator.userAgent,
792
- version: details.browser.version || '',
793
- };
794
- clientDetails.os = {
795
- name: details.os.name || '',
796
- version: details.os.version || '',
797
- architecture: details.cpu.architecture || '',
798
- };
799
- clientDetails.device = {
800
- name: `${details.device.vendor || ''} ${details.device.model || ''} ${
801
- details.device.type || ''
802
- }`,
803
- version: '',
804
- };
805
- }
806
- clientDetails.sdk = getSdkInfo();
807
835
  // 1. wait for the signal server to be ready before sending "joinRequest"
808
836
  sfuClient.signalReady
809
837
  .catch((err) => this.logger('error', 'Signal ready failed', err))
@@ -818,31 +846,52 @@ export class Call {
818
846
  ),
819
847
  )
820
848
  .then((sdp) => {
821
- const joinRequest = {
849
+ const subscriptions = getCurrentValue(this.trackSubscriptionsSubject);
850
+ const migration: Migration | undefined = isMigrating
851
+ ? {
852
+ fromSfuId: data?.migrating_from || '',
853
+ subscriptions: subscriptions.data || [],
854
+ announcedTracks: this.publisher?.getCurrentTrackInfos() || [],
855
+ }
856
+ : undefined;
857
+
858
+ return sfuClient.join({
822
859
  subscriberSdp: sdp || '',
823
- clientDetails,
824
- };
825
- this.logger('info', 'Sending join request to SFU');
826
- this.logger('debug', 'Join request payload', joinRequest);
827
- sfuClient.join(joinRequest);
860
+ clientDetails: getClientDetails(),
861
+ migration,
862
+ });
828
863
  });
829
864
 
830
865
  // 2. in parallel, wait for the SFU to send us the "joinResponse"
831
866
  // this will throw an error if the SFU rejects the join request or
832
867
  // fails to respond in time
833
868
  const { callState } = await this.waitForJoinResponse();
869
+ if (isMigrating) {
870
+ await this.subscriber.migrateTo(sfuClient, connectionConfig);
871
+ await this.publisher.migrateTo(sfuClient, connectionConfig);
872
+ }
834
873
  const currentParticipants = callState?.participants || [];
835
874
  const participantCount = callState?.participantCount;
836
875
  const startedAt = callState?.startedAt
837
876
  ? Timestamp.toDate(callState.startedAt)
838
877
  : new Date();
839
- this.state.setParticipants(
840
- currentParticipants.map<StreamVideoParticipant>((participant) => ({
841
- ...participant,
842
- isLocalParticipant: participant.sessionId === sfuClient.sessionId,
843
- viewportVisibilityState: VisibilityState.UNKNOWN,
844
- })),
845
- );
878
+ this.state.setParticipants(() => {
879
+ const participantLookup = this.state.getParticipantLookupBySessionId();
880
+ return currentParticipants.map((p) => {
881
+ const participant: StreamVideoParticipant = Object.assign(p, {
882
+ isLocalParticipant: p.sessionId === sfuClient.sessionId,
883
+ viewportVisibilityState: VisibilityState.UNKNOWN,
884
+ });
885
+ // We need to preserve some of the local state of the participant
886
+ // (e.g. videoDimension, visibilityState, pinnedAt, etc.)
887
+ // as it doesn't exist on the server.
888
+ const existingParticipant = participantLookup[p.sessionId];
889
+ return reconcileParticipantLocalState(
890
+ participant,
891
+ existingParticipant,
892
+ );
893
+ });
894
+ });
846
895
  this.state.setParticipantCount(participantCount?.total || 0);
847
896
  this.state.setAnonymousParticipantCount(participantCount?.anonymous || 0);
848
897
  this.state.setStartedAt(startedAt);
@@ -853,12 +902,20 @@ export class Call {
853
902
  } catch (err) {
854
903
  // join failed, try to rejoin
855
904
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
905
+ this.logger(
906
+ 'error',
907
+ `[Rejoin]: Rejoin ${this.reconnectAttempts} failed.`,
908
+ err,
909
+ );
856
910
  await rejoin();
857
- this.logger('info', `Rejoin ${this.reconnectAttempts} successful!`);
911
+ this.logger(
912
+ 'info',
913
+ `[Rejoin]: Rejoin ${this.reconnectAttempts} successful!`,
914
+ );
858
915
  } else {
859
916
  this.logger(
860
917
  'error',
861
- `Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
918
+ `[Rejoin]: Rejoin failed for ${this.reconnectAttempts} times. Giving up.`,
862
919
  );
863
920
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
864
921
  throw new Error('Join failed');
@@ -866,6 +923,30 @@ export class Call {
866
923
  }
867
924
  };
868
925
 
926
+ private waitForJoinResponse = (timeout: number = 5000) => {
927
+ return new Promise<JoinResponse>((resolve, reject) => {
928
+ const unsubscribe = this.on('joinResponse', (event) => {
929
+ if (event.eventPayload.oneofKind !== 'joinResponse') return;
930
+ clearTimeout(timeoutId);
931
+ unsubscribe();
932
+ resolve(event.eventPayload.joinResponse);
933
+ });
934
+
935
+ const timeoutId = setTimeout(() => {
936
+ unsubscribe();
937
+ reject(new Error('Waiting for "joinResponse" has timed out'));
938
+ }, timeout);
939
+ });
940
+ };
941
+
942
+ private assertCallJoined = () => {
943
+ return new Promise<void>((resolve) => {
944
+ this.state.callingState$
945
+ .pipe(takeWhile((state) => state !== CallingState.JOINED, true))
946
+ .subscribe(() => resolve());
947
+ });
948
+ };
949
+
869
950
  /**
870
951
  * Starts publishing the given video stream to the call.
871
952
  * The stream will be stopped if the user changes an input device, or if the user leaves the call.
@@ -1151,81 +1232,6 @@ export class Call {
1151
1232
  return this.publisher?.updateVideoPublishQuality(enabledRids);
1152
1233
  };
1153
1234
 
1154
- private handleOnTrack = (e: RTCTrackEvent) => {
1155
- const [primaryStream] = e.streams;
1156
- // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
1157
- const [trackId, trackType] = primaryStream.id.split(':');
1158
- this.logger('info', `Got remote ${trackType} track:`);
1159
- this.logger('debug', `Track: `, e.track);
1160
- const participantToUpdate = this.state.participants.find(
1161
- (p) => p.trackLookupPrefix === trackId,
1162
- );
1163
- if (!participantToUpdate) {
1164
- this.logger(
1165
- 'error',
1166
- `'Received track for unknown participant: ${trackId}'`,
1167
- e,
1168
- );
1169
- return;
1170
- }
1171
-
1172
- e.track.addEventListener('mute', () => {
1173
- this.logger(
1174
- 'info',
1175
- `Track muted: ${participantToUpdate.userId} ${trackType}:${trackId}`,
1176
- );
1177
- });
1178
-
1179
- e.track.addEventListener('unmute', () => {
1180
- this.logger(
1181
- 'info',
1182
- `Track unmuted: ${participantToUpdate.userId} ${trackType}:${trackId}`,
1183
- );
1184
- });
1185
-
1186
- e.track.addEventListener('ended', () => {
1187
- this.logger(
1188
- 'info',
1189
- `Track ended: ${participantToUpdate.userId} ${trackType}:${trackId}`,
1190
- );
1191
- });
1192
-
1193
- const streamKindProp = (
1194
- {
1195
- TRACK_TYPE_AUDIO: 'audioStream',
1196
- TRACK_TYPE_VIDEO: 'videoStream',
1197
- TRACK_TYPE_SCREEN_SHARE: 'screenShareStream',
1198
- } as const
1199
- )[trackType];
1200
-
1201
- if (!streamKindProp) {
1202
- this.logger('error', `Unknown track type: ${trackType}`);
1203
- return;
1204
- }
1205
- const previousStream = participantToUpdate[streamKindProp];
1206
- if (previousStream) {
1207
- this.logger(
1208
- 'info',
1209
- `Cleaning up previous remote tracks: ${e.track.kind}`,
1210
- );
1211
- previousStream.getTracks().forEach((t) => {
1212
- t.stop();
1213
- previousStream.removeTrack(t);
1214
- });
1215
- }
1216
- this.state.updateParticipant(participantToUpdate.sessionId, {
1217
- [streamKindProp]: primaryStream,
1218
- });
1219
- };
1220
-
1221
- private assertCallJoined = () => {
1222
- return new Promise<void>((resolve) => {
1223
- this.state.callingState$
1224
- .pipe(takeWhile((state) => state !== CallingState.JOINED, true))
1225
- .subscribe(() => resolve());
1226
- });
1227
- };
1228
-
1229
1235
  /**
1230
1236
  * Sends a reaction to the other call participants.
1231
1237
  *
@@ -1605,16 +1611,14 @@ export class Call {
1605
1611
  };
1606
1612
 
1607
1613
  /**
1608
- * Sends an event to all call participants.
1614
+ * Sends a custom event to all call participants.
1609
1615
  *
1610
1616
  * @param event the event to send.
1611
1617
  */
1612
- sendEvent = async (
1613
- event: SendEventRequest & { type: StreamCallEvent['type'] },
1614
- ) => {
1618
+ sendCustomEvent = async (payload: { [key: string]: any }) => {
1615
1619
  return this.streamClient.post<SendEventResponse, SendEventRequest>(
1616
1620
  `${this.streamClientBasePath}/event`,
1617
- event,
1621
+ { custom: payload },
1618
1622
  );
1619
1623
  };
1620
1624
  }