@stream-io/video-client 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/index.browser.es.js +1532 -1784
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1512 -1783
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1532 -1784
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -28
  9. package/dist/src/StreamSfuClient.d.ts +4 -5
  10. package/dist/src/devices/CameraManager.d.ts +5 -8
  11. package/dist/src/devices/InputMediaDeviceManager.d.ts +5 -5
  12. package/dist/src/devices/MicrophoneManager.d.ts +7 -2
  13. package/dist/src/devices/ScreenShareManager.d.ts +1 -2
  14. package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
  15. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  16. package/dist/src/helpers/array.d.ts +7 -0
  17. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  18. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  19. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  20. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  21. package/dist/src/rtc/Publisher.d.ts +32 -86
  22. package/dist/src/rtc/Subscriber.d.ts +4 -56
  23. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  24. package/dist/src/rtc/codecs.d.ts +1 -15
  25. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  26. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  27. package/dist/src/rtc/index.d.ts +3 -0
  28. package/dist/src/rtc/videoLayers.d.ts +11 -25
  29. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  30. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  31. package/dist/src/stats/index.d.ts +1 -1
  32. package/dist/src/stats/types.d.ts +8 -0
  33. package/dist/src/types.d.ts +12 -22
  34. package/package.json +1 -1
  35. package/src/Call.ts +254 -268
  36. package/src/StreamSfuClient.ts +9 -14
  37. package/src/StreamVideoClient.ts +1 -1
  38. package/src/__tests__/Call.publishing.test.ts +306 -0
  39. package/src/devices/CameraManager.ts +33 -16
  40. package/src/devices/InputMediaDeviceManager.ts +36 -27
  41. package/src/devices/MicrophoneManager.ts +29 -8
  42. package/src/devices/ScreenShareManager.ts +6 -8
  43. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  44. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  45. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  46. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  47. package/src/devices/__tests__/mocks.ts +1 -0
  48. package/src/events/__tests__/internal.test.ts +132 -0
  49. package/src/events/__tests__/mutes.test.ts +0 -3
  50. package/src/events/__tests__/speaker.test.ts +92 -0
  51. package/src/events/participant.ts +3 -4
  52. package/src/gen/video/sfu/event/events.ts +91 -30
  53. package/src/gen/video/sfu/models/models.ts +105 -13
  54. package/src/helpers/array.ts +14 -0
  55. package/src/permissions/PermissionsContext.ts +22 -0
  56. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  57. package/src/rpc/__tests__/createClient.test.ts +38 -0
  58. package/src/rpc/createClient.ts +11 -5
  59. package/src/rtc/BasePeerConnection.ts +240 -0
  60. package/src/rtc/Dispatcher.ts +0 -9
  61. package/src/rtc/IceTrickleBuffer.ts +24 -4
  62. package/src/rtc/Publisher.ts +210 -528
  63. package/src/rtc/Subscriber.ts +26 -200
  64. package/src/rtc/TransceiverCache.ts +120 -0
  65. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  66. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  67. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  68. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  69. package/src/rtc/codecs.ts +1 -131
  70. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  71. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  72. package/src/rtc/helpers/sdp.ts +30 -0
  73. package/src/rtc/helpers/tracks.ts +3 -0
  74. package/src/rtc/index.ts +4 -0
  75. package/src/rtc/videoLayers.ts +68 -76
  76. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  77. package/src/stats/SfuStatsReporter.ts +31 -3
  78. package/src/stats/index.ts +1 -1
  79. package/src/stats/types.ts +12 -0
  80. package/src/types.ts +12 -22
  81. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  82. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  83. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  84. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  85. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  86. package/src/helpers/sdp-munging.ts +0 -265
  87. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  88. package/src/rtc/__tests__/codecs.test.ts +0 -145
  89. package/src/rtc/bitrateLookup.ts +0 -61
  90. package/src/rtc/helpers/iceCandidate.ts +0 -16
  91. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  92. /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
package/src/Call.ts CHANGED
@@ -2,12 +2,14 @@ import { StreamSfuClient } from './StreamSfuClient';
2
2
  import {
3
3
  Dispatcher,
4
4
  getGenericSdp,
5
+ isAudioTrackType,
5
6
  isSfuEvent,
7
+ muteTypeToTrackType,
6
8
  Publisher,
7
9
  Subscriber,
10
+ toRtcConfiguration,
11
+ trackTypeToParticipantStreamKey,
8
12
  } from './rtc';
9
- import { muteTypeToTrackType } from './rtc/helpers/tracks';
10
- import { toRtcConfiguration } from './rtc/helpers/rtcConfiguration';
11
13
  import {
12
14
  registerEventHandlers,
13
15
  registerRingingCallEventHandlers,
@@ -27,6 +29,7 @@ import type {
27
29
  BlockUserRequest,
28
30
  BlockUserResponse,
29
31
  CallRingEvent,
32
+ CallSettingsResponse,
30
33
  CollectUserFeedbackRequest,
31
34
  CollectUserFeedbackResponse,
32
35
  Credentials,
@@ -86,9 +89,9 @@ import {
86
89
  AudioTrackType,
87
90
  CallConstructor,
88
91
  CallLeaveOptions,
92
+ ClientPublishOptions,
89
93
  ClosedCaptionsSettings,
90
94
  JoinCallData,
91
- PublishOptions,
92
95
  TrackMuteType,
93
96
  VideoTrackType,
94
97
  } from './types';
@@ -96,6 +99,9 @@ import { BehaviorSubject, Subject, takeWhile } from 'rxjs';
96
99
  import { ReconnectDetails } from './gen/video/sfu/event/events';
97
100
  import {
98
101
  ClientDetails,
102
+ Codec,
103
+ PublishOption,
104
+ SubscribeOption,
99
105
  TrackType,
100
106
  WebsocketReconnectStrategy,
101
107
  } from './gen/video/sfu/models/models';
@@ -116,7 +122,6 @@ import {
116
122
  import { getClientDetails } from './client-details';
117
123
  import { getLogger } from './logger';
118
124
  import {
119
- CameraDirection,
120
125
  CameraManager,
121
126
  MicrophoneManager,
122
127
  ScreenShareManager,
@@ -125,6 +130,7 @@ import {
125
130
  import { getSdkSignature } from './stats/utils';
126
131
  import { withoutConcurrency } from './helpers/concurrency';
127
132
  import { ensureExhausted } from './helpers/ensureExhausted';
133
+ import { pushToIfMissing } from './helpers/array';
128
134
  import {
129
135
  makeSafePromise,
130
136
  PromiseWithResolvers,
@@ -206,7 +212,8 @@ export class Call {
206
212
  */
207
213
  private readonly dispatcher = new Dispatcher();
208
214
 
209
- private publishOptions?: PublishOptions;
215
+ private clientPublishOptions?: ClientPublishOptions;
216
+ private currentPublishOptions?: PublishOption[];
210
217
  private statsReporter?: StatsReporter;
211
218
  private sfuStatsReporter?: SfuStatsReporter;
212
219
  private dropTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -292,7 +299,7 @@ export class Call {
292
299
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
293
300
  }
294
301
 
295
- private async setup() {
302
+ private setup = async () => {
296
303
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
297
304
  if (this.initialized) return;
298
305
 
@@ -303,6 +310,12 @@ export class Call {
303
310
  }),
304
311
  );
305
312
 
313
+ this.leaveCallHooks.add(
314
+ this.on('changePublishOptions', (event) => {
315
+ this.currentPublishOptions = event.publishOptions;
316
+ }),
317
+ );
318
+
306
319
  this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
307
320
  this.registerEffects();
308
321
  this.registerReconnectHandlers();
@@ -313,9 +326,9 @@ export class Call {
313
326
 
314
327
  this.initialized = true;
315
328
  });
316
- }
329
+ };
317
330
 
318
- private registerEffects() {
331
+ private registerEffects = () => {
319
332
  this.leaveCallHooks.add(
320
333
  // handles updating the permissions context when the settings change.
321
334
  createSubscription(this.state.settings$, (settings) => {
@@ -406,7 +419,7 @@ export class Call {
406
419
  }
407
420
  }),
408
421
  );
409
- }
422
+ };
410
423
 
411
424
  private handleOwnCapabilitiesUpdated = async (
412
425
  ownCapabilities: OwnCapability[],
@@ -545,10 +558,10 @@ export class Call {
545
558
  this.sfuStatsReporter?.stop();
546
559
  this.sfuStatsReporter = undefined;
547
560
 
548
- this.subscriber?.close();
561
+ this.subscriber?.dispose();
549
562
  this.subscriber = undefined;
550
563
 
551
- this.publisher?.close({ stopTracks: true });
564
+ this.publisher?.dispose();
552
565
  this.publisher = undefined;
553
566
 
554
567
  await this.sfuClient?.leaveAndClose(reason);
@@ -613,9 +626,8 @@ export class Call {
613
626
  // call.ring event excludes the call creator in the members list
614
627
  // as the creator does not get the ring event
615
628
  // so update the member list accordingly
616
- const creator = this.state.members.find(
617
- (m) => m.user.id === event.call.created_by.id,
618
- );
629
+ const { created_by, settings } = event.call;
630
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
619
631
  if (!creator) {
620
632
  this.state.setMembers(event.members);
621
633
  } else {
@@ -629,7 +641,7 @@ export class Call {
629
641
  // const calls = useCalls().filter((c) => c.ringing);
630
642
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
631
643
  this.clientStore.setCalls([this, ...calls]);
632
- await this.applyDeviceConfig(false);
644
+ await this.applyDeviceConfig(settings, false);
633
645
  };
634
646
 
635
647
  /**
@@ -664,7 +676,7 @@ export class Call {
664
676
  this.clientStore.registerCall(this);
665
677
  }
666
678
 
667
- await this.applyDeviceConfig(false);
679
+ await this.applyDeviceConfig(response.call.settings, false);
668
680
 
669
681
  return response;
670
682
  };
@@ -695,7 +707,7 @@ export class Call {
695
707
  this.clientStore.registerCall(this);
696
708
  }
697
709
 
698
- await this.applyDeviceConfig(false);
710
+ await this.applyDeviceConfig(response.call.settings, false);
699
711
 
700
712
  return response;
701
713
  };
@@ -820,20 +832,35 @@ export class Call {
820
832
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
821
833
  if (previousSfuClient !== sfuClient) {
822
834
  // prepare a generic SDP and send it to the SFU.
823
- // this is a throw-away SDP that the SFU will use to determine
835
+ // these are throw-away SDPs that the SFU will use to determine
824
836
  // the capabilities of the client (codec support, etc.)
825
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
826
- const reconnectDetails =
827
- this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
828
- ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
829
- : undefined;
830
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
831
- subscriberSdp: receivingCapabilitiesSdp,
832
- publisherSdp: '',
833
- clientDetails,
834
- fastReconnect: performingFastReconnect,
835
- reconnectDetails,
836
- });
837
+ const [subscriberSdp, publisherSdp] = await Promise.all([
838
+ getGenericSdp('recvonly'),
839
+ getGenericSdp('sendonly'),
840
+ ]);
841
+ const isReconnecting =
842
+ this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
843
+ const reconnectDetails = isReconnecting
844
+ ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
845
+ : undefined;
846
+ const preferredPublishOptions = !isReconnecting
847
+ ? this.getPreferredPublishOptions()
848
+ : this.currentPublishOptions || [];
849
+ const preferredSubscribeOptions = !isReconnecting
850
+ ? this.getPreferredSubscribeOptions()
851
+ : [];
852
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } =
853
+ await sfuClient.join({
854
+ subscriberSdp,
855
+ publisherSdp,
856
+ clientDetails,
857
+ fastReconnect: performingFastReconnect,
858
+ reconnectDetails,
859
+ preferredPublishOptions,
860
+ preferredSubscribeOptions,
861
+ });
862
+
863
+ this.currentPublishOptions = publishOptions;
837
864
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
838
865
  if (callState) {
839
866
  this.state.updateFromSfuCallState(
@@ -863,18 +890,16 @@ export class Call {
863
890
  connectionConfig,
864
891
  clientDetails,
865
892
  statsOptions,
893
+ publishOptions: this.currentPublishOptions || [],
866
894
  closePreviousInstances: !performingMigration,
867
895
  });
868
896
  }
869
897
 
870
898
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
871
899
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
872
- this.sfuStatsReporter?.sendTelemetryData({
873
- data: {
874
- oneofKind: 'connectionTimeSeconds',
875
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
876
- },
877
- });
900
+ this.sfuStatsReporter?.sendConnectionTime(
901
+ (Date.now() - connectStartTime) / 1000,
902
+ );
878
903
  }
879
904
 
880
905
  if (performingRejoin) {
@@ -891,8 +916,8 @@ export class Call {
891
916
 
892
917
  // device settings should be applied only once, we don't have to
893
918
  // re-apply them on later reconnections or server-side data fetches
894
- if (!this.deviceSettingsAppliedOnce) {
895
- await this.applyDeviceConfig(true);
919
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
920
+ await this.applyDeviceConfig(this.state.settings, true);
896
921
  this.deviceSettingsAppliedOnce = true;
897
922
  }
898
923
 
@@ -902,6 +927,8 @@ export class Call {
902
927
  // we will spam the other participants with push notifications and `call.ring` events.
903
928
  delete this.joinCallData?.ring;
904
929
  delete this.joinCallData?.notify;
930
+ // reset the reconnect strategy to unspecified after a successful reconnection
931
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
905
932
 
906
933
  this.logger('info', `Joined call ${this.cid}`);
907
934
  };
@@ -916,7 +943,8 @@ export class Call {
916
943
  ): ReconnectDetails => {
917
944
  const strategy = this.reconnectStrategy;
918
945
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
919
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
946
+ const announcedTracks =
947
+ this.publisher?.getAnnouncedTracksForReconnect() || [];
920
948
  return {
921
949
  strategy,
922
950
  announcedTracks,
@@ -927,6 +955,62 @@ export class Call {
927
955
  };
928
956
  };
929
957
 
958
+ /**
959
+ * Prepares the preferred codec for the call.
960
+ * This is an experimental client feature and subject to change.
961
+ * @internal
962
+ */
963
+ private getPreferredPublishOptions = (): PublishOption[] => {
964
+ const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } =
965
+ this.clientPublishOptions || {};
966
+ if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers) return [];
967
+
968
+ const codec = preferredCodec
969
+ ? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
970
+ : undefined;
971
+
972
+ const preferredPublishOptions = [
973
+ PublishOption.create({
974
+ trackType: TrackType.VIDEO,
975
+ codec,
976
+ bitrate: preferredBitrate,
977
+ maxSpatialLayers: maxSimulcastLayers,
978
+ }),
979
+ ];
980
+
981
+ const screenShareSettings = this.screenShare.getSettings();
982
+ if (screenShareSettings) {
983
+ preferredPublishOptions.push(
984
+ PublishOption.create({
985
+ trackType: TrackType.SCREEN_SHARE,
986
+ fps: screenShareSettings.maxFramerate,
987
+ bitrate: screenShareSettings.maxBitrate,
988
+ }),
989
+ );
990
+ }
991
+
992
+ return preferredPublishOptions;
993
+ };
994
+
995
+ /**
996
+ * Prepares the preferred options for subscribing to tracks.
997
+ * This is an experimental client feature and subject to change.
998
+ * @internal
999
+ */
1000
+ private getPreferredSubscribeOptions = (): SubscribeOption[] => {
1001
+ const { subscriberCodec, subscriberFmtpLine } =
1002
+ this.clientPublishOptions || {};
1003
+ if (!subscriberCodec || !subscriberFmtpLine) return [];
1004
+ return [
1005
+ SubscribeOption.create({
1006
+ trackType: TrackType.VIDEO,
1007
+ codecs: [
1008
+ { name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
1009
+ ],
1010
+ }),
1011
+ ];
1012
+ };
1013
+
930
1014
  /**
931
1015
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
932
1016
  * Uses the provided SFU client to restore the ICE connection.
@@ -965,6 +1049,7 @@ export class Call {
965
1049
  connectionConfig: RTCConfiguration;
966
1050
  statsOptions: StatsOptions;
967
1051
  clientDetails: ClientDetails;
1052
+ publishOptions: PublishOption[];
968
1053
  closePreviousInstances: boolean;
969
1054
  }) => {
970
1055
  const {
@@ -972,10 +1057,11 @@ export class Call {
972
1057
  connectionConfig,
973
1058
  clientDetails,
974
1059
  statsOptions,
1060
+ publishOptions,
975
1061
  closePreviousInstances,
976
1062
  } = opts;
977
1063
  if (closePreviousInstances && this.subscriber) {
978
- this.subscriber.close();
1064
+ this.subscriber.dispose();
979
1065
  }
980
1066
  this.subscriber = new Subscriber({
981
1067
  sfuClient,
@@ -999,18 +1085,14 @@ export class Call {
999
1085
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
1000
1086
  if (!isAnonymous) {
1001
1087
  if (closePreviousInstances && this.publisher) {
1002
- this.publisher.close({ stopTracks: false });
1088
+ this.publisher.dispose();
1003
1089
  }
1004
- const audioSettings = this.state.settings?.audio;
1005
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
1006
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
1007
1090
  this.publisher = new Publisher({
1008
1091
  sfuClient,
1009
1092
  dispatcher: this.dispatcher,
1010
1093
  state: this.state,
1011
1094
  connectionConfig,
1012
- isDtxEnabled,
1013
- isRedEnabled,
1095
+ publishOptions,
1014
1096
  logTag: String(this.sfuClientTag),
1015
1097
  onUnrecoverableError: () => {
1016
1098
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -1200,19 +1282,14 @@ export class Call {
1200
1282
  * @internal
1201
1283
  */
1202
1284
  private reconnectFast = async () => {
1203
- let reconnectStartTime = Date.now();
1285
+ const reconnectStartTime = Date.now();
1204
1286
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
1205
1287
  this.state.setCallingState(CallingState.RECONNECTING);
1206
1288
  await this.join(this.joinCallData);
1207
- this.sfuStatsReporter?.sendTelemetryData({
1208
- data: {
1209
- oneofKind: 'reconnection',
1210
- reconnection: {
1211
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1212
- strategy: WebsocketReconnectStrategy.FAST,
1213
- },
1214
- },
1215
- });
1289
+ this.sfuStatsReporter?.sendReconnectionTime(
1290
+ WebsocketReconnectStrategy.FAST,
1291
+ (Date.now() - reconnectStartTime) / 1000,
1292
+ );
1216
1293
  };
1217
1294
 
1218
1295
  /**
@@ -1220,21 +1297,16 @@ export class Call {
1220
1297
  * @internal
1221
1298
  */
1222
1299
  private reconnectRejoin = async () => {
1223
- let reconnectStartTime = Date.now();
1300
+ const reconnectStartTime = Date.now();
1224
1301
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
1225
1302
  this.state.setCallingState(CallingState.RECONNECTING);
1226
1303
  await this.join(this.joinCallData);
1227
1304
  await this.restorePublishedTracks();
1228
1305
  this.restoreSubscribedTracks();
1229
- this.sfuStatsReporter?.sendTelemetryData({
1230
- data: {
1231
- oneofKind: 'reconnection',
1232
- reconnection: {
1233
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1234
- strategy: WebsocketReconnectStrategy.REJOIN,
1235
- },
1236
- },
1237
- });
1306
+ this.sfuStatsReporter?.sendReconnectionTime(
1307
+ WebsocketReconnectStrategy.REJOIN,
1308
+ (Date.now() - reconnectStartTime) / 1000,
1309
+ );
1238
1310
  };
1239
1311
 
1240
1312
  /**
@@ -1242,7 +1314,7 @@ export class Call {
1242
1314
  * @internal
1243
1315
  */
1244
1316
  private reconnectMigrate = async () => {
1245
- let reconnectStartTime = Date.now();
1317
+ const reconnectStartTime = Date.now();
1246
1318
  const currentSfuClient = this.sfuClient;
1247
1319
  if (!currentSfuClient) {
1248
1320
  throw new Error('Cannot migrate without an active SFU client');
@@ -1281,21 +1353,16 @@ export class Call {
1281
1353
  // the `migrationTask`
1282
1354
  this.state.setCallingState(CallingState.JOINED);
1283
1355
  } finally {
1284
- currentSubscriber?.close();
1285
- currentPublisher?.close({ stopTracks: false });
1356
+ currentSubscriber?.dispose();
1357
+ currentPublisher?.dispose();
1286
1358
 
1287
1359
  // and close the previous SFU client, without specifying close code
1288
1360
  currentSfuClient.close();
1289
1361
  }
1290
- this.sfuStatsReporter?.sendTelemetryData({
1291
- data: {
1292
- oneofKind: 'reconnection',
1293
- reconnection: {
1294
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1295
- strategy: WebsocketReconnectStrategy.MIGRATE,
1296
- },
1297
- },
1298
- });
1362
+ this.sfuStatsReporter?.sendReconnectionTime(
1363
+ WebsocketReconnectStrategy.MIGRATE,
1364
+ (Date.now() - reconnectStartTime) / 1000,
1365
+ );
1299
1366
  };
1300
1367
 
1301
1368
  /**
@@ -1384,22 +1451,16 @@ export class Call {
1384
1451
  // the tracks need to be restored in their original order of publishing
1385
1452
  // otherwise, we might get `m-lines order mismatch` errors
1386
1453
  for (const trackType of this.trackPublishOrder) {
1454
+ let mediaStream: MediaStream | undefined;
1387
1455
  switch (trackType) {
1388
1456
  case TrackType.AUDIO:
1389
- const audioStream = this.microphone.state.mediaStream;
1390
- if (audioStream) {
1391
- await this.publishAudioStream(audioStream);
1392
- }
1457
+ mediaStream = this.microphone.state.mediaStream;
1393
1458
  break;
1394
1459
  case TrackType.VIDEO:
1395
- const videoStream = this.camera.state.mediaStream;
1396
- if (videoStream) await this.publishVideoStream(videoStream);
1460
+ mediaStream = this.camera.state.mediaStream;
1397
1461
  break;
1398
1462
  case TrackType.SCREEN_SHARE:
1399
- const screenShareStream = this.screenShare.state.mediaStream;
1400
- if (screenShareStream) {
1401
- await this.publishScreenShareStream(screenShareStream);
1402
- }
1463
+ mediaStream = this.screenShare.state.mediaStream;
1403
1464
  break;
1404
1465
  // screen share audio can't exist without a screen share, so we handle it there
1405
1466
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -1409,6 +1470,8 @@ export class Call {
1409
1470
  ensureExhausted(trackType, 'Unknown track type');
1410
1471
  break;
1411
1472
  }
1473
+
1474
+ if (mediaStream) await this.publish(mediaStream, trackType);
1412
1475
  }
1413
1476
  };
1414
1477
 
@@ -1424,136 +1487,112 @@ export class Call {
1424
1487
 
1425
1488
  /**
1426
1489
  * Starts publishing the given video stream to the call.
1427
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
1428
- *
1429
- * Consecutive calls to this method will replace the previously published stream.
1430
- * The previous video stream will be stopped.
1431
- *
1432
- * @param videoStream the video stream to publish.
1490
+ * @deprecated use `call.publish()`.
1433
1491
  */
1434
1492
  publishVideoStream = async (videoStream: MediaStream) => {
1435
- if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1436
- // joining is in progress, and we should wait until the client is ready
1437
- await this.sfuClient.joinTask;
1438
-
1439
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
1440
- throw new Error('No permission to publish video');
1441
- }
1442
-
1443
- if (!this.publisher) throw new Error('Publisher is not initialized');
1444
-
1445
- const [videoTrack] = videoStream.getVideoTracks();
1446
- if (!videoTrack) throw new Error('There is no video track in the stream');
1447
-
1448
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
1449
- this.trackPublishOrder.push(TrackType.VIDEO);
1450
- }
1451
-
1452
- await this.publisher.publishStream(
1453
- videoStream,
1454
- videoTrack,
1455
- TrackType.VIDEO,
1456
- this.publishOptions,
1457
- );
1493
+ await this.publish(videoStream, TrackType.VIDEO);
1458
1494
  };
1459
1495
 
1460
1496
  /**
1461
1497
  * Starts publishing the given audio stream to the call.
1462
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
1463
- *
1464
- * Consecutive calls to this method will replace the audio stream that is currently being published.
1465
- * The previous audio stream will be stopped.
1466
- *
1467
- * @param audioStream the audio stream to publish.
1498
+ * @deprecated use `call.publish()`
1468
1499
  */
1469
1500
  publishAudioStream = async (audioStream: MediaStream) => {
1470
- if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1471
- // joining is in progress, and we should wait until the client is ready
1472
- await this.sfuClient.joinTask;
1473
-
1474
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
1475
- throw new Error('No permission to publish audio');
1476
- }
1477
-
1478
- if (!this.publisher) throw new Error('Publisher is not initialized');
1479
-
1480
- const [audioTrack] = audioStream.getAudioTracks();
1481
- if (!audioTrack) throw new Error('There is no audio track in the stream');
1482
-
1483
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
1484
- this.trackPublishOrder.push(TrackType.AUDIO);
1485
- }
1486
- await this.publisher.publishStream(
1487
- audioStream,
1488
- audioTrack,
1489
- TrackType.AUDIO,
1490
- );
1501
+ await this.publish(audioStream, TrackType.AUDIO);
1491
1502
  };
1492
1503
 
1493
1504
  /**
1494
1505
  * Starts publishing the given screen-share stream to the call.
1495
- *
1496
- * Consecutive calls to this method will replace the previous screen-share stream.
1497
- * The previous screen-share stream will be stopped.
1498
- *
1499
- * @param screenShareStream the screen-share stream to publish.
1506
+ * @deprecated use `call.publish()`
1500
1507
  */
1501
1508
  publishScreenShareStream = async (screenShareStream: MediaStream) => {
1509
+ await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
1510
+ };
1511
+
1512
+ /**
1513
+ * Publishes the given media stream.
1514
+ *
1515
+ * @param mediaStream the media stream to publish.
1516
+ * @param trackType the type of the track to announce.
1517
+ */
1518
+ publish = async (mediaStream: MediaStream, trackType: TrackType) => {
1502
1519
  if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1503
1520
  // joining is in progress, and we should wait until the client is ready
1504
1521
  await this.sfuClient.joinTask;
1505
1522
 
1506
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
1507
- throw new Error('No permission to publish screen share');
1523
+ if (!this.permissionsContext.canPublish(trackType)) {
1524
+ throw new Error(`No permission to publish ${TrackType[trackType]}`);
1508
1525
  }
1509
1526
 
1510
1527
  if (!this.publisher) throw new Error('Publisher is not initialized');
1511
1528
 
1512
- const [screenShareTrack] = screenShareStream.getVideoTracks();
1513
- if (!screenShareTrack) {
1514
- throw new Error('There is no screen share track in the stream');
1529
+ const [track] = isAudioTrackType(trackType)
1530
+ ? mediaStream.getAudioTracks()
1531
+ : mediaStream.getVideoTracks();
1532
+
1533
+ if (!track) {
1534
+ throw new Error(
1535
+ `There is no ${TrackType[trackType]} track in the stream`,
1536
+ );
1515
1537
  }
1516
1538
 
1517
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
1518
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
1539
+ if (track.readyState === 'ended') {
1540
+ throw new Error(`Can't publish ended tracks.`);
1519
1541
  }
1520
- const opts: PublishOptions = {
1521
- screenShareSettings: this.screenShare.getSettings(),
1522
- };
1523
- await this.publisher.publishStream(
1524
- screenShareStream,
1525
- screenShareTrack,
1526
- TrackType.SCREEN_SHARE,
1527
- opts,
1528
- );
1529
1542
 
1530
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
1531
- if (screenShareAudioTrack) {
1532
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
1533
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
1543
+ pushToIfMissing(this.trackPublishOrder, trackType);
1544
+ await this.publisher.publish(track, trackType);
1545
+
1546
+ const trackTypes = [trackType];
1547
+ if (trackType === TrackType.SCREEN_SHARE) {
1548
+ const [audioTrack] = mediaStream.getAudioTracks();
1549
+ if (audioTrack) {
1550
+ pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
1551
+ await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
1552
+ trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
1534
1553
  }
1535
- await this.publisher.publishStream(
1536
- screenShareStream,
1537
- screenShareAudioTrack,
1538
- TrackType.SCREEN_SHARE_AUDIO,
1539
- opts,
1540
- );
1541
1554
  }
1555
+
1556
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
1542
1557
  };
1543
1558
 
1544
1559
  /**
1545
1560
  * Stops publishing the given track type to the call, if it is currently being published.
1546
- * Underlying track will be stopped and removed from the publisher.
1547
1561
  *
1548
- * @param trackType the track type to stop publishing.
1549
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
1562
+ * @param trackTypes the track types to stop publishing.
1550
1563
  */
1551
- stopPublish = async (trackType: TrackType, stopTrack: boolean = true) => {
1552
- this.logger(
1553
- 'info',
1554
- `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`,
1555
- );
1556
- await this.publisher?.unpublishStream(trackType, stopTrack);
1564
+ stopPublish = async (...trackTypes: TrackType[]) => {
1565
+ if (!this.sfuClient || !this.publisher) return;
1566
+ this.publisher.stopTracks(...trackTypes);
1567
+ await this.updateLocalStreamState(undefined, ...trackTypes);
1568
+ };
1569
+
1570
+ /**
1571
+ * Updates the call state with the new stream.
1572
+ *
1573
+ * @param mediaStream the new stream to update the call state with.
1574
+ * If undefined, the stream will be removed from the call state.
1575
+ * @param trackTypes the track types to update the call state with.
1576
+ */
1577
+ private updateLocalStreamState = async (
1578
+ mediaStream: MediaStream | undefined,
1579
+ ...trackTypes: TrackType[]
1580
+ ) => {
1581
+ if (!this.sfuClient || !this.sfuClient.sessionId) return;
1582
+ await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
1583
+
1584
+ const { sessionId } = this.sfuClient;
1585
+ for (const trackType of trackTypes) {
1586
+ const streamStateProp = trackTypeToParticipantStreamKey(trackType);
1587
+ if (!streamStateProp) continue;
1588
+
1589
+ this.state.updateParticipant(sessionId, (p) => ({
1590
+ publishedTracks: mediaStream
1591
+ ? pushToIfMissing([...p.publishedTracks], trackType)
1592
+ : p.publishedTracks.filter((t) => t !== trackType),
1593
+ [streamStateProp]: mediaStream,
1594
+ }));
1595
+ }
1557
1596
  };
1558
1597
 
1559
1598
  /**
@@ -1562,9 +1601,20 @@ export class Call {
1562
1601
  * @internal
1563
1602
  * @param options the options to use.
1564
1603
  */
1565
- updatePublishOptions(options: PublishOptions) {
1566
- this.publishOptions = { ...this.publishOptions, ...options };
1567
- }
1604
+ updatePublishOptions = (options: ClientPublishOptions) => {
1605
+ this.logger(
1606
+ 'warn',
1607
+ '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
1608
+ 'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.',
1609
+ );
1610
+ if (this.state.callingState === CallingState.JOINED) {
1611
+ this.logger(
1612
+ 'warn',
1613
+ 'Updating publish options after joining the call does not have an effect',
1614
+ );
1615
+ }
1616
+ this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
1617
+ };
1568
1618
 
1569
1619
  /**
1570
1620
  * Notifies the SFU that a noise cancellation process has started.
@@ -1588,6 +1638,17 @@ export class Call {
1588
1638
  });
1589
1639
  };
1590
1640
 
1641
+ /**
1642
+ * Notifies the SFU about the mute state of the given track types.
1643
+ * @internal
1644
+ */
1645
+ notifyTrackMuteState = async (muted: boolean, ...trackTypes: TrackType[]) => {
1646
+ if (!this.sfuClient) return;
1647
+ await this.sfuClient.updateMuteStates(
1648
+ trackTypes.map((trackType) => ({ trackType, muted })),
1649
+ );
1650
+ };
1651
+
1591
1652
  /**
1592
1653
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
1593
1654
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -2202,93 +2263,18 @@ export class Call {
2202
2263
  *
2203
2264
  * @internal
2204
2265
  */
2205
- applyDeviceConfig = async (status: boolean) => {
2206
- await this.initCamera({ setStatus: status }).catch((err) => {
2266
+ applyDeviceConfig = async (
2267
+ settings: CallSettingsResponse,
2268
+ publish: boolean,
2269
+ ) => {
2270
+ await this.camera.apply(settings.video, publish).catch((err) => {
2207
2271
  this.logger('warn', 'Camera init failed', err);
2208
2272
  });
2209
- await this.initMic({ setStatus: status }).catch((err) => {
2273
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
2210
2274
  this.logger('warn', 'Mic init failed', err);
2211
2275
  });
2212
2276
  };
2213
2277
 
2214
- private initCamera = async (options: { setStatus: boolean }) => {
2215
- // Wait for any in progress camera operation
2216
- await this.camera.statusChangeSettled();
2217
-
2218
- if (
2219
- this.state.localParticipant?.videoStream ||
2220
- !this.permissionsContext.hasPermission('send-video')
2221
- ) {
2222
- return;
2223
- }
2224
-
2225
- // Set camera direction if it's not yet set
2226
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
2227
- let defaultDirection: CameraDirection = 'front';
2228
- const backendSetting = this.state.settings?.video.camera_facing;
2229
- if (backendSetting) {
2230
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
2231
- }
2232
- this.camera.state.setDirection(defaultDirection);
2233
- }
2234
-
2235
- // Set target resolution
2236
- const targetResolution = this.state.settings?.video.target_resolution;
2237
- if (targetResolution) {
2238
- await this.camera.selectTargetResolution(targetResolution);
2239
- }
2240
-
2241
- if (options.setStatus) {
2242
- // Publish already that was set before we joined
2243
- if (
2244
- this.camera.enabled &&
2245
- this.camera.state.mediaStream &&
2246
- !this.publisher?.isPublishing(TrackType.VIDEO)
2247
- ) {
2248
- await this.publishVideoStream(this.camera.state.mediaStream);
2249
- }
2250
-
2251
- // Start camera if backend config specifies, and there is no local setting
2252
- if (
2253
- this.camera.state.status === undefined &&
2254
- this.state.settings?.video.camera_default_on
2255
- ) {
2256
- await this.camera.enable();
2257
- }
2258
- }
2259
- };
2260
-
2261
- private initMic = async (options: { setStatus: boolean }) => {
2262
- // Wait for any in progress mic operation
2263
- await this.microphone.statusChangeSettled();
2264
-
2265
- if (
2266
- this.state.localParticipant?.audioStream ||
2267
- !this.permissionsContext.hasPermission('send-audio')
2268
- ) {
2269
- return;
2270
- }
2271
-
2272
- if (options.setStatus) {
2273
- // Publish media stream that was set before we joined
2274
- if (
2275
- this.microphone.enabled &&
2276
- this.microphone.state.mediaStream &&
2277
- !this.publisher?.isPublishing(TrackType.AUDIO)
2278
- ) {
2279
- await this.publishAudioStream(this.microphone.state.mediaStream);
2280
- }
2281
-
2282
- // Start mic if backend config specifies, and there is no local setting
2283
- if (
2284
- this.microphone.state.status === undefined &&
2285
- this.state.settings?.audio.mic_default_on
2286
- ) {
2287
- await this.microphone.enable();
2288
- }
2289
- }
2290
- };
2291
-
2292
2278
  /**
2293
2279
  * Will begin tracking the given element for visibility changes within the
2294
2280
  * configured viewport element (`call.setViewport`).