@stream-io/video-client 1.13.1 → 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 (99) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +1704 -1762
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1706 -1780
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1704 -1762
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +61 -30
  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/coordinator/index.d.ts +904 -515
  15. package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
  16. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  17. package/dist/src/helpers/array.d.ts +7 -0
  18. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  20. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  21. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  22. package/dist/src/rtc/Publisher.d.ts +32 -86
  23. package/dist/src/rtc/Subscriber.d.ts +4 -56
  24. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  25. package/dist/src/rtc/codecs.d.ts +1 -15
  26. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  27. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  28. package/dist/src/rtc/index.d.ts +3 -0
  29. package/dist/src/rtc/videoLayers.d.ts +11 -25
  30. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  31. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  32. package/dist/src/stats/index.d.ts +1 -1
  33. package/dist/src/stats/types.d.ts +8 -0
  34. package/dist/src/store/CallState.d.ts +47 -5
  35. package/dist/src/store/rxUtils.d.ts +15 -1
  36. package/dist/src/types.d.ts +26 -22
  37. package/package.json +1 -1
  38. package/src/Call.ts +310 -271
  39. package/src/StreamSfuClient.ts +9 -14
  40. package/src/StreamVideoClient.ts +1 -1
  41. package/src/__tests__/Call.publishing.test.ts +306 -0
  42. package/src/devices/CameraManager.ts +33 -16
  43. package/src/devices/InputMediaDeviceManager.ts +36 -27
  44. package/src/devices/MicrophoneManager.ts +29 -8
  45. package/src/devices/ScreenShareManager.ts +6 -8
  46. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  47. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  48. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  50. package/src/devices/__tests__/mocks.ts +1 -0
  51. package/src/events/__tests__/internal.test.ts +132 -0
  52. package/src/events/__tests__/mutes.test.ts +0 -3
  53. package/src/events/__tests__/speaker.test.ts +92 -0
  54. package/src/events/participant.ts +3 -4
  55. package/src/gen/coordinator/index.ts +902 -514
  56. package/src/gen/video/sfu/event/events.ts +91 -30
  57. package/src/gen/video/sfu/models/models.ts +105 -13
  58. package/src/helpers/array.ts +14 -0
  59. package/src/permissions/PermissionsContext.ts +22 -0
  60. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  61. package/src/rpc/__tests__/createClient.test.ts +38 -0
  62. package/src/rpc/createClient.ts +11 -5
  63. package/src/rtc/BasePeerConnection.ts +240 -0
  64. package/src/rtc/Dispatcher.ts +0 -9
  65. package/src/rtc/IceTrickleBuffer.ts +24 -4
  66. package/src/rtc/Publisher.ts +210 -528
  67. package/src/rtc/Subscriber.ts +26 -200
  68. package/src/rtc/TransceiverCache.ts +120 -0
  69. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  70. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  71. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  72. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  73. package/src/rtc/codecs.ts +1 -131
  74. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  75. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  76. package/src/rtc/helpers/sdp.ts +30 -0
  77. package/src/rtc/helpers/tracks.ts +3 -0
  78. package/src/rtc/index.ts +4 -0
  79. package/src/rtc/videoLayers.ts +68 -76
  80. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  81. package/src/stats/SfuStatsReporter.ts +31 -3
  82. package/src/stats/index.ts +1 -1
  83. package/src/stats/types.ts +12 -0
  84. package/src/store/CallState.ts +115 -5
  85. package/src/store/__tests__/CallState.test.ts +101 -0
  86. package/src/store/rxUtils.ts +23 -1
  87. package/src/types.ts +27 -22
  88. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  89. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  90. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  91. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  92. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  93. package/src/helpers/sdp-munging.ts +0 -265
  94. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  95. package/src/rtc/__tests__/codecs.test.ts +0 -145
  96. package/src/rtc/bitrateLookup.ts +0 -61
  97. package/src/rtc/helpers/iceCandidate.ts +0 -16
  98. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  99. /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,
@@ -55,12 +58,16 @@ import type {
55
58
  SendCallEventResponse,
56
59
  SendReactionRequest,
57
60
  SendReactionResponse,
61
+ StartClosedCaptionsRequest,
62
+ StartClosedCaptionsResponse,
58
63
  StartHLSBroadcastingResponse,
59
64
  StartRecordingRequest,
60
65
  StartRecordingResponse,
61
66
  StartTranscriptionRequest,
62
67
  StartTranscriptionResponse,
63
68
  StatsOptions,
69
+ StopClosedCaptionsRequest,
70
+ StopClosedCaptionsResponse,
64
71
  StopHLSBroadcastingResponse,
65
72
  StopLiveResponse,
66
73
  StopRecordingResponse,
@@ -75,15 +82,16 @@ import type {
75
82
  UpdateCallResponse,
76
83
  UpdateUserPermissionsRequest,
77
84
  UpdateUserPermissionsResponse,
78
- VideoResolution,
85
+ VideoDimension,
79
86
  } from './gen/coordinator';
80
87
  import { OwnCapability } from './gen/coordinator';
81
88
  import {
82
89
  AudioTrackType,
83
90
  CallConstructor,
84
91
  CallLeaveOptions,
92
+ ClientPublishOptions,
93
+ ClosedCaptionsSettings,
85
94
  JoinCallData,
86
- PublishOptions,
87
95
  TrackMuteType,
88
96
  VideoTrackType,
89
97
  } from './types';
@@ -91,6 +99,9 @@ import { BehaviorSubject, Subject, takeWhile } from 'rxjs';
91
99
  import { ReconnectDetails } from './gen/video/sfu/event/events';
92
100
  import {
93
101
  ClientDetails,
102
+ Codec,
103
+ PublishOption,
104
+ SubscribeOption,
94
105
  TrackType,
95
106
  WebsocketReconnectStrategy,
96
107
  } from './gen/video/sfu/models/models';
@@ -111,7 +122,6 @@ import {
111
122
  import { getClientDetails } from './client-details';
112
123
  import { getLogger } from './logger';
113
124
  import {
114
- CameraDirection,
115
125
  CameraManager,
116
126
  MicrophoneManager,
117
127
  ScreenShareManager,
@@ -120,6 +130,7 @@ import {
120
130
  import { getSdkSignature } from './stats/utils';
121
131
  import { withoutConcurrency } from './helpers/concurrency';
122
132
  import { ensureExhausted } from './helpers/ensureExhausted';
133
+ import { pushToIfMissing } from './helpers/array';
123
134
  import {
124
135
  makeSafePromise,
125
136
  PromiseWithResolvers,
@@ -201,7 +212,8 @@ export class Call {
201
212
  */
202
213
  private readonly dispatcher = new Dispatcher();
203
214
 
204
- private publishOptions?: PublishOptions;
215
+ private clientPublishOptions?: ClientPublishOptions;
216
+ private currentPublishOptions?: PublishOption[];
205
217
  private statsReporter?: StatsReporter;
206
218
  private sfuStatsReporter?: SfuStatsReporter;
207
219
  private dropTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -287,7 +299,7 @@ export class Call {
287
299
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
288
300
  }
289
301
 
290
- private async setup() {
302
+ private setup = async () => {
291
303
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
292
304
  if (this.initialized) return;
293
305
 
@@ -298,6 +310,12 @@ export class Call {
298
310
  }),
299
311
  );
300
312
 
313
+ this.leaveCallHooks.add(
314
+ this.on('changePublishOptions', (event) => {
315
+ this.currentPublishOptions = event.publishOptions;
316
+ }),
317
+ );
318
+
301
319
  this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
302
320
  this.registerEffects();
303
321
  this.registerReconnectHandlers();
@@ -308,9 +326,9 @@ export class Call {
308
326
 
309
327
  this.initialized = true;
310
328
  });
311
- }
329
+ };
312
330
 
313
- private registerEffects() {
331
+ private registerEffects = () => {
314
332
  this.leaveCallHooks.add(
315
333
  // handles updating the permissions context when the settings change.
316
334
  createSubscription(this.state.settings$, (settings) => {
@@ -401,7 +419,7 @@ export class Call {
401
419
  }
402
420
  }),
403
421
  );
404
- }
422
+ };
405
423
 
406
424
  private handleOwnCapabilitiesUpdated = async (
407
425
  ownCapabilities: OwnCapability[],
@@ -540,10 +558,10 @@ export class Call {
540
558
  this.sfuStatsReporter?.stop();
541
559
  this.sfuStatsReporter = undefined;
542
560
 
543
- this.subscriber?.close();
561
+ this.subscriber?.dispose();
544
562
  this.subscriber = undefined;
545
563
 
546
- this.publisher?.close({ stopTracks: true });
564
+ this.publisher?.dispose();
547
565
  this.publisher = undefined;
548
566
 
549
567
  await this.sfuClient?.leaveAndClose(reason);
@@ -551,6 +569,7 @@ export class Call {
551
569
  this.dynascaleManager.setSfuClient(undefined);
552
570
 
553
571
  this.state.setCallingState(CallingState.LEFT);
572
+ this.state.dispose();
554
573
 
555
574
  // Call all leave call hooks, e.g. to clean up global event handlers
556
575
  this.leaveCallHooks.forEach((hook) => hook());
@@ -607,9 +626,8 @@ export class Call {
607
626
  // call.ring event excludes the call creator in the members list
608
627
  // as the creator does not get the ring event
609
628
  // so update the member list accordingly
610
- const creator = this.state.members.find(
611
- (m) => m.user.id === event.call.created_by.id,
612
- );
629
+ const { created_by, settings } = event.call;
630
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
613
631
  if (!creator) {
614
632
  this.state.setMembers(event.members);
615
633
  } else {
@@ -623,7 +641,7 @@ export class Call {
623
641
  // const calls = useCalls().filter((c) => c.ringing);
624
642
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
625
643
  this.clientStore.setCalls([this, ...calls]);
626
- await this.applyDeviceConfig(false);
644
+ await this.applyDeviceConfig(settings, false);
627
645
  };
628
646
 
629
647
  /**
@@ -658,7 +676,7 @@ export class Call {
658
676
  this.clientStore.registerCall(this);
659
677
  }
660
678
 
661
- await this.applyDeviceConfig(false);
679
+ await this.applyDeviceConfig(response.call.settings, false);
662
680
 
663
681
  return response;
664
682
  };
@@ -689,7 +707,7 @@ export class Call {
689
707
  this.clientStore.registerCall(this);
690
708
  }
691
709
 
692
- await this.applyDeviceConfig(false);
710
+ await this.applyDeviceConfig(response.call.settings, false);
693
711
 
694
712
  return response;
695
713
  };
@@ -814,20 +832,35 @@ export class Call {
814
832
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
815
833
  if (previousSfuClient !== sfuClient) {
816
834
  // prepare a generic SDP and send it to the SFU.
817
- // 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
818
836
  // the capabilities of the client (codec support, etc.)
819
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
820
- const reconnectDetails =
821
- this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
822
- ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
823
- : undefined;
824
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
825
- subscriberSdp: receivingCapabilitiesSdp,
826
- publisherSdp: '',
827
- clientDetails,
828
- fastReconnect: performingFastReconnect,
829
- reconnectDetails,
830
- });
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;
831
864
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
832
865
  if (callState) {
833
866
  this.state.updateFromSfuCallState(
@@ -857,18 +890,16 @@ export class Call {
857
890
  connectionConfig,
858
891
  clientDetails,
859
892
  statsOptions,
893
+ publishOptions: this.currentPublishOptions || [],
860
894
  closePreviousInstances: !performingMigration,
861
895
  });
862
896
  }
863
897
 
864
898
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
865
899
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
866
- this.sfuStatsReporter?.sendTelemetryData({
867
- data: {
868
- oneofKind: 'connectionTimeSeconds',
869
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
870
- },
871
- });
900
+ this.sfuStatsReporter?.sendConnectionTime(
901
+ (Date.now() - connectStartTime) / 1000,
902
+ );
872
903
  }
873
904
 
874
905
  if (performingRejoin) {
@@ -885,8 +916,8 @@ export class Call {
885
916
 
886
917
  // device settings should be applied only once, we don't have to
887
918
  // re-apply them on later reconnections or server-side data fetches
888
- if (!this.deviceSettingsAppliedOnce) {
889
- await this.applyDeviceConfig(true);
919
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
920
+ await this.applyDeviceConfig(this.state.settings, true);
890
921
  this.deviceSettingsAppliedOnce = true;
891
922
  }
892
923
 
@@ -896,6 +927,8 @@ export class Call {
896
927
  // we will spam the other participants with push notifications and `call.ring` events.
897
928
  delete this.joinCallData?.ring;
898
929
  delete this.joinCallData?.notify;
930
+ // reset the reconnect strategy to unspecified after a successful reconnection
931
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
899
932
 
900
933
  this.logger('info', `Joined call ${this.cid}`);
901
934
  };
@@ -910,7 +943,8 @@ export class Call {
910
943
  ): ReconnectDetails => {
911
944
  const strategy = this.reconnectStrategy;
912
945
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
913
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
946
+ const announcedTracks =
947
+ this.publisher?.getAnnouncedTracksForReconnect() || [];
914
948
  return {
915
949
  strategy,
916
950
  announcedTracks,
@@ -921,6 +955,62 @@ export class Call {
921
955
  };
922
956
  };
923
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
+
924
1014
  /**
925
1015
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
926
1016
  * Uses the provided SFU client to restore the ICE connection.
@@ -959,6 +1049,7 @@ export class Call {
959
1049
  connectionConfig: RTCConfiguration;
960
1050
  statsOptions: StatsOptions;
961
1051
  clientDetails: ClientDetails;
1052
+ publishOptions: PublishOption[];
962
1053
  closePreviousInstances: boolean;
963
1054
  }) => {
964
1055
  const {
@@ -966,10 +1057,11 @@ export class Call {
966
1057
  connectionConfig,
967
1058
  clientDetails,
968
1059
  statsOptions,
1060
+ publishOptions,
969
1061
  closePreviousInstances,
970
1062
  } = opts;
971
1063
  if (closePreviousInstances && this.subscriber) {
972
- this.subscriber.close();
1064
+ this.subscriber.dispose();
973
1065
  }
974
1066
  this.subscriber = new Subscriber({
975
1067
  sfuClient,
@@ -993,18 +1085,14 @@ export class Call {
993
1085
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
994
1086
  if (!isAnonymous) {
995
1087
  if (closePreviousInstances && this.publisher) {
996
- this.publisher.close({ stopTracks: false });
1088
+ this.publisher.dispose();
997
1089
  }
998
- const audioSettings = this.state.settings?.audio;
999
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
1000
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
1001
1090
  this.publisher = new Publisher({
1002
1091
  sfuClient,
1003
1092
  dispatcher: this.dispatcher,
1004
1093
  state: this.state,
1005
1094
  connectionConfig,
1006
- isDtxEnabled,
1007
- isRedEnabled,
1095
+ publishOptions,
1008
1096
  logTag: String(this.sfuClientTag),
1009
1097
  onUnrecoverableError: () => {
1010
1098
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -1194,19 +1282,14 @@ export class Call {
1194
1282
  * @internal
1195
1283
  */
1196
1284
  private reconnectFast = async () => {
1197
- let reconnectStartTime = Date.now();
1285
+ const reconnectStartTime = Date.now();
1198
1286
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
1199
1287
  this.state.setCallingState(CallingState.RECONNECTING);
1200
1288
  await this.join(this.joinCallData);
1201
- this.sfuStatsReporter?.sendTelemetryData({
1202
- data: {
1203
- oneofKind: 'reconnection',
1204
- reconnection: {
1205
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1206
- strategy: WebsocketReconnectStrategy.FAST,
1207
- },
1208
- },
1209
- });
1289
+ this.sfuStatsReporter?.sendReconnectionTime(
1290
+ WebsocketReconnectStrategy.FAST,
1291
+ (Date.now() - reconnectStartTime) / 1000,
1292
+ );
1210
1293
  };
1211
1294
 
1212
1295
  /**
@@ -1214,21 +1297,16 @@ export class Call {
1214
1297
  * @internal
1215
1298
  */
1216
1299
  private reconnectRejoin = async () => {
1217
- let reconnectStartTime = Date.now();
1300
+ const reconnectStartTime = Date.now();
1218
1301
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
1219
1302
  this.state.setCallingState(CallingState.RECONNECTING);
1220
1303
  await this.join(this.joinCallData);
1221
1304
  await this.restorePublishedTracks();
1222
1305
  this.restoreSubscribedTracks();
1223
- this.sfuStatsReporter?.sendTelemetryData({
1224
- data: {
1225
- oneofKind: 'reconnection',
1226
- reconnection: {
1227
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1228
- strategy: WebsocketReconnectStrategy.REJOIN,
1229
- },
1230
- },
1231
- });
1306
+ this.sfuStatsReporter?.sendReconnectionTime(
1307
+ WebsocketReconnectStrategy.REJOIN,
1308
+ (Date.now() - reconnectStartTime) / 1000,
1309
+ );
1232
1310
  };
1233
1311
 
1234
1312
  /**
@@ -1236,7 +1314,7 @@ export class Call {
1236
1314
  * @internal
1237
1315
  */
1238
1316
  private reconnectMigrate = async () => {
1239
- let reconnectStartTime = Date.now();
1317
+ const reconnectStartTime = Date.now();
1240
1318
  const currentSfuClient = this.sfuClient;
1241
1319
  if (!currentSfuClient) {
1242
1320
  throw new Error('Cannot migrate without an active SFU client');
@@ -1275,21 +1353,16 @@ export class Call {
1275
1353
  // the `migrationTask`
1276
1354
  this.state.setCallingState(CallingState.JOINED);
1277
1355
  } finally {
1278
- currentSubscriber?.close();
1279
- currentPublisher?.close({ stopTracks: false });
1356
+ currentSubscriber?.dispose();
1357
+ currentPublisher?.dispose();
1280
1358
 
1281
1359
  // and close the previous SFU client, without specifying close code
1282
1360
  currentSfuClient.close();
1283
1361
  }
1284
- this.sfuStatsReporter?.sendTelemetryData({
1285
- data: {
1286
- oneofKind: 'reconnection',
1287
- reconnection: {
1288
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
1289
- strategy: WebsocketReconnectStrategy.MIGRATE,
1290
- },
1291
- },
1292
- });
1362
+ this.sfuStatsReporter?.sendReconnectionTime(
1363
+ WebsocketReconnectStrategy.MIGRATE,
1364
+ (Date.now() - reconnectStartTime) / 1000,
1365
+ );
1293
1366
  };
1294
1367
 
1295
1368
  /**
@@ -1378,22 +1451,16 @@ export class Call {
1378
1451
  // the tracks need to be restored in their original order of publishing
1379
1452
  // otherwise, we might get `m-lines order mismatch` errors
1380
1453
  for (const trackType of this.trackPublishOrder) {
1454
+ let mediaStream: MediaStream | undefined;
1381
1455
  switch (trackType) {
1382
1456
  case TrackType.AUDIO:
1383
- const audioStream = this.microphone.state.mediaStream;
1384
- if (audioStream) {
1385
- await this.publishAudioStream(audioStream);
1386
- }
1457
+ mediaStream = this.microphone.state.mediaStream;
1387
1458
  break;
1388
1459
  case TrackType.VIDEO:
1389
- const videoStream = this.camera.state.mediaStream;
1390
- if (videoStream) await this.publishVideoStream(videoStream);
1460
+ mediaStream = this.camera.state.mediaStream;
1391
1461
  break;
1392
1462
  case TrackType.SCREEN_SHARE:
1393
- const screenShareStream = this.screenShare.state.mediaStream;
1394
- if (screenShareStream) {
1395
- await this.publishScreenShareStream(screenShareStream);
1396
- }
1463
+ mediaStream = this.screenShare.state.mediaStream;
1397
1464
  break;
1398
1465
  // screen share audio can't exist without a screen share, so we handle it there
1399
1466
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -1403,6 +1470,8 @@ export class Call {
1403
1470
  ensureExhausted(trackType, 'Unknown track type');
1404
1471
  break;
1405
1472
  }
1473
+
1474
+ if (mediaStream) await this.publish(mediaStream, trackType);
1406
1475
  }
1407
1476
  };
1408
1477
 
@@ -1418,136 +1487,112 @@ export class Call {
1418
1487
 
1419
1488
  /**
1420
1489
  * Starts publishing the given video stream to the call.
1421
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
1422
- *
1423
- * Consecutive calls to this method will replace the previously published stream.
1424
- * The previous video stream will be stopped.
1425
- *
1426
- * @param videoStream the video stream to publish.
1490
+ * @deprecated use `call.publish()`.
1427
1491
  */
1428
1492
  publishVideoStream = async (videoStream: MediaStream) => {
1429
- if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1430
- // joining is in progress, and we should wait until the client is ready
1431
- await this.sfuClient.joinTask;
1432
-
1433
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
1434
- throw new Error('No permission to publish video');
1435
- }
1436
-
1437
- if (!this.publisher) throw new Error('Publisher is not initialized');
1438
-
1439
- const [videoTrack] = videoStream.getVideoTracks();
1440
- if (!videoTrack) throw new Error('There is no video track in the stream');
1441
-
1442
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
1443
- this.trackPublishOrder.push(TrackType.VIDEO);
1444
- }
1445
-
1446
- await this.publisher.publishStream(
1447
- videoStream,
1448
- videoTrack,
1449
- TrackType.VIDEO,
1450
- this.publishOptions,
1451
- );
1493
+ await this.publish(videoStream, TrackType.VIDEO);
1452
1494
  };
1453
1495
 
1454
1496
  /**
1455
1497
  * Starts publishing the given audio stream to the call.
1456
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
1457
- *
1458
- * Consecutive calls to this method will replace the audio stream that is currently being published.
1459
- * The previous audio stream will be stopped.
1460
- *
1461
- * @param audioStream the audio stream to publish.
1498
+ * @deprecated use `call.publish()`
1462
1499
  */
1463
1500
  publishAudioStream = async (audioStream: MediaStream) => {
1464
- if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1465
- // joining is in progress, and we should wait until the client is ready
1466
- await this.sfuClient.joinTask;
1467
-
1468
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
1469
- throw new Error('No permission to publish audio');
1470
- }
1471
-
1472
- if (!this.publisher) throw new Error('Publisher is not initialized');
1473
-
1474
- const [audioTrack] = audioStream.getAudioTracks();
1475
- if (!audioTrack) throw new Error('There is no audio track in the stream');
1476
-
1477
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
1478
- this.trackPublishOrder.push(TrackType.AUDIO);
1479
- }
1480
- await this.publisher.publishStream(
1481
- audioStream,
1482
- audioTrack,
1483
- TrackType.AUDIO,
1484
- );
1501
+ await this.publish(audioStream, TrackType.AUDIO);
1485
1502
  };
1486
1503
 
1487
1504
  /**
1488
1505
  * Starts publishing the given screen-share stream to the call.
1489
- *
1490
- * Consecutive calls to this method will replace the previous screen-share stream.
1491
- * The previous screen-share stream will be stopped.
1492
- *
1493
- * @param screenShareStream the screen-share stream to publish.
1506
+ * @deprecated use `call.publish()`
1494
1507
  */
1495
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) => {
1496
1519
  if (!this.sfuClient) throw new Error(`Call not joined yet.`);
1497
1520
  // joining is in progress, and we should wait until the client is ready
1498
1521
  await this.sfuClient.joinTask;
1499
1522
 
1500
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
1501
- 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]}`);
1502
1525
  }
1503
1526
 
1504
1527
  if (!this.publisher) throw new Error('Publisher is not initialized');
1505
1528
 
1506
- const [screenShareTrack] = screenShareStream.getVideoTracks();
1507
- if (!screenShareTrack) {
1508
- 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
+ );
1509
1537
  }
1510
1538
 
1511
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
1512
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
1539
+ if (track.readyState === 'ended') {
1540
+ throw new Error(`Can't publish ended tracks.`);
1513
1541
  }
1514
- const opts: PublishOptions = {
1515
- screenShareSettings: this.screenShare.getSettings(),
1516
- };
1517
- await this.publisher.publishStream(
1518
- screenShareStream,
1519
- screenShareTrack,
1520
- TrackType.SCREEN_SHARE,
1521
- opts,
1522
- );
1523
1542
 
1524
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
1525
- if (screenShareAudioTrack) {
1526
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
1527
- 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);
1528
1553
  }
1529
- await this.publisher.publishStream(
1530
- screenShareStream,
1531
- screenShareAudioTrack,
1532
- TrackType.SCREEN_SHARE_AUDIO,
1533
- opts,
1534
- );
1535
1554
  }
1555
+
1556
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
1536
1557
  };
1537
1558
 
1538
1559
  /**
1539
1560
  * Stops publishing the given track type to the call, if it is currently being published.
1540
- * Underlying track will be stopped and removed from the publisher.
1541
1561
  *
1542
- * @param trackType the track type to stop publishing.
1543
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
1562
+ * @param trackTypes the track types to stop publishing.
1544
1563
  */
1545
- stopPublish = async (trackType: TrackType, stopTrack: boolean = true) => {
1546
- this.logger(
1547
- 'info',
1548
- `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`,
1549
- );
1550
- 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
+ }
1551
1596
  };
1552
1597
 
1553
1598
  /**
@@ -1556,9 +1601,20 @@ export class Call {
1556
1601
  * @internal
1557
1602
  * @param options the options to use.
1558
1603
  */
1559
- updatePublishOptions(options: PublishOptions) {
1560
- this.publishOptions = { ...this.publishOptions, ...options };
1561
- }
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
+ };
1562
1618
 
1563
1619
  /**
1564
1620
  * Notifies the SFU that a noise cancellation process has started.
@@ -1582,6 +1638,17 @@ export class Call {
1582
1638
  });
1583
1639
  };
1584
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
+
1585
1652
  /**
1586
1653
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
1587
1654
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -1771,7 +1838,54 @@ export class Call {
1771
1838
  };
1772
1839
 
1773
1840
  /**
1774
- * Sends a `call.permission_request` event to all users connected to the call. The call settings object contains infomration about which permissions can be requested during a call (for example a user might be allowed to request permission to publish audio, but not video).
1841
+ * Starts the closed captions of the call.
1842
+ */
1843
+ startClosedCaptions = async (
1844
+ options?: StartClosedCaptionsRequest,
1845
+ ): Promise<StartClosedCaptionsResponse> => {
1846
+ const trx = this.state.setCaptioning(true); // optimistic update
1847
+ try {
1848
+ return await this.streamClient.post<
1849
+ StartClosedCaptionsResponse,
1850
+ StartClosedCaptionsRequest
1851
+ >(`${this.streamClientBasePath}/start_closed_captions`, options);
1852
+ } catch (err) {
1853
+ trx.rollback(); // revert the optimistic update
1854
+ throw err;
1855
+ }
1856
+ };
1857
+
1858
+ /**
1859
+ * Stops the closed captions of the call.
1860
+ */
1861
+ stopClosedCaptions = async (
1862
+ options?: StopClosedCaptionsRequest,
1863
+ ): Promise<StopClosedCaptionsResponse> => {
1864
+ const trx = this.state.setCaptioning(false); // optimistic update
1865
+ try {
1866
+ return await this.streamClient.post<
1867
+ StopClosedCaptionsResponse,
1868
+ StopClosedCaptionsRequest
1869
+ >(`${this.streamClientBasePath}/stop_closed_captions`, options);
1870
+ } catch (err) {
1871
+ trx.rollback(); // revert the optimistic update
1872
+ throw err;
1873
+ }
1874
+ };
1875
+
1876
+ /**
1877
+ * Updates the closed caption settings.
1878
+ *
1879
+ * @param config the closed caption settings to apply
1880
+ */
1881
+ updateClosedCaptionSettings = (config: Partial<ClosedCaptionsSettings>) => {
1882
+ this.state.updateClosedCaptionSettings(config);
1883
+ };
1884
+
1885
+ /**
1886
+ * Sends a `call.permission_request` event to all users connected to the call.
1887
+ * The call settings object contains information about which permissions can be requested during a call
1888
+ * (for example, a user might be allowed to request permission to publish audio, but not video).
1775
1889
  */
1776
1890
  requestPermissions = async (
1777
1891
  data: RequestPermissionRequest,
@@ -2149,93 +2263,18 @@ export class Call {
2149
2263
  *
2150
2264
  * @internal
2151
2265
  */
2152
- applyDeviceConfig = async (status: boolean) => {
2153
- 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) => {
2154
2271
  this.logger('warn', 'Camera init failed', err);
2155
2272
  });
2156
- await this.initMic({ setStatus: status }).catch((err) => {
2273
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
2157
2274
  this.logger('warn', 'Mic init failed', err);
2158
2275
  });
2159
2276
  };
2160
2277
 
2161
- private initCamera = async (options: { setStatus: boolean }) => {
2162
- // Wait for any in progress camera operation
2163
- await this.camera.statusChangeSettled();
2164
-
2165
- if (
2166
- this.state.localParticipant?.videoStream ||
2167
- !this.permissionsContext.hasPermission('send-video')
2168
- ) {
2169
- return;
2170
- }
2171
-
2172
- // Set camera direction if it's not yet set
2173
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
2174
- let defaultDirection: CameraDirection = 'front';
2175
- const backendSetting = this.state.settings?.video.camera_facing;
2176
- if (backendSetting) {
2177
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
2178
- }
2179
- this.camera.state.setDirection(defaultDirection);
2180
- }
2181
-
2182
- // Set target resolution
2183
- const targetResolution = this.state.settings?.video.target_resolution;
2184
- if (targetResolution) {
2185
- await this.camera.selectTargetResolution(targetResolution);
2186
- }
2187
-
2188
- if (options.setStatus) {
2189
- // Publish already that was set before we joined
2190
- if (
2191
- this.camera.enabled &&
2192
- this.camera.state.mediaStream &&
2193
- !this.publisher?.isPublishing(TrackType.VIDEO)
2194
- ) {
2195
- await this.publishVideoStream(this.camera.state.mediaStream);
2196
- }
2197
-
2198
- // Start camera if backend config specifies, and there is no local setting
2199
- if (
2200
- this.camera.state.status === undefined &&
2201
- this.state.settings?.video.camera_default_on
2202
- ) {
2203
- await this.camera.enable();
2204
- }
2205
- }
2206
- };
2207
-
2208
- private initMic = async (options: { setStatus: boolean }) => {
2209
- // Wait for any in progress mic operation
2210
- await this.microphone.statusChangeSettled();
2211
-
2212
- if (
2213
- this.state.localParticipant?.audioStream ||
2214
- !this.permissionsContext.hasPermission('send-audio')
2215
- ) {
2216
- return;
2217
- }
2218
-
2219
- if (options.setStatus) {
2220
- // Publish media stream that was set before we joined
2221
- if (
2222
- this.microphone.enabled &&
2223
- this.microphone.state.mediaStream &&
2224
- !this.publisher?.isPublishing(TrackType.AUDIO)
2225
- ) {
2226
- await this.publishAudioStream(this.microphone.state.mediaStream);
2227
- }
2228
-
2229
- // Start mic if backend config specifies, and there is no local setting
2230
- if (
2231
- this.microphone.state.status === undefined &&
2232
- this.state.settings?.audio.mic_default_on
2233
- ) {
2234
- await this.microphone.enable();
2235
- }
2236
- }
2237
- };
2238
-
2239
2278
  /**
2240
2279
  * Will begin tracking the given element for visibility changes within the
2241
2280
  * configured viewport element (`call.setViewport`).
@@ -2377,7 +2416,7 @@ export class Call {
2377
2416
  * preference has effect on. Affects all participants by default.
2378
2417
  */
2379
2418
  setPreferredIncomingVideoResolution = (
2380
- resolution: VideoResolution | undefined,
2419
+ resolution: VideoDimension | undefined,
2381
2420
  sessionIds?: string[],
2382
2421
  ) => {
2383
2422
  this.dynascaleManager.setVideoTrackSubscriptionOverrides(