@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
@@ -4,9 +4,9 @@ import { ServiceType, stackIntercept, RpcError } from '@protobuf-ts/runtime-rpc'
4
4
  import axios from 'axios';
5
5
  export { AxiosError } from 'axios';
6
6
  import { TwirpFetchTransport, TwirpErrorCode } from '@protobuf-ts/twirp-transport';
7
- import { UAParser } from 'ua-parser-js';
8
7
  import { ReplaySubject, combineLatest, BehaviorSubject, map, shareReplay, distinctUntilChanged, takeWhile, distinctUntilKeyChanged, fromEventPattern, startWith, concatMap, from, fromEvent, debounceTime, merge, pairwise, of } from 'rxjs';
9
- import * as SDP from 'sdp-transform';
8
+ import { parse } from 'sdp-transform';
9
+ import { UAParser } from 'ua-parser-js';
10
10
 
11
11
  /* tslint:disable */
12
12
  /* eslint-disable */
@@ -56,6 +56,48 @@ const ChannelConfigWithInfoBlocklistBehaviorEnum = {
56
56
  BLOCK: 'block',
57
57
  SHADOW_BLOCK: 'shadow_block',
58
58
  };
59
+ /**
60
+ * All possibility of string to use
61
+ * @export
62
+ */
63
+ const ChannelOwnCapability = {
64
+ BAN_CHANNEL_MEMBERS: 'ban-channel-members',
65
+ CAST_POLL_VOTE: 'cast-poll-vote',
66
+ CONNECT_EVENTS: 'connect-events',
67
+ CREATE_ATTACHMENT: 'create-attachment',
68
+ CREATE_CALL: 'create-call',
69
+ DELETE_ANY_MESSAGE: 'delete-any-message',
70
+ DELETE_CHANNEL: 'delete-channel',
71
+ DELETE_OWN_MESSAGE: 'delete-own-message',
72
+ FLAG_MESSAGE: 'flag-message',
73
+ FREEZE_CHANNEL: 'freeze-channel',
74
+ JOIN_CALL: 'join-call',
75
+ JOIN_CHANNEL: 'join-channel',
76
+ LEAVE_CHANNEL: 'leave-channel',
77
+ MUTE_CHANNEL: 'mute-channel',
78
+ PIN_MESSAGE: 'pin-message',
79
+ QUERY_POLL_VOTES: 'query-poll-votes',
80
+ QUOTE_MESSAGE: 'quote-message',
81
+ READ_EVENTS: 'read-events',
82
+ SEARCH_MESSAGES: 'search-messages',
83
+ SEND_CUSTOM_EVENTS: 'send-custom-events',
84
+ SEND_LINKS: 'send-links',
85
+ SEND_MESSAGE: 'send-message',
86
+ SEND_POLL: 'send-poll',
87
+ SEND_REACTION: 'send-reaction',
88
+ SEND_REPLY: 'send-reply',
89
+ SEND_TYPING_EVENTS: 'send-typing-events',
90
+ SET_CHANNEL_COOLDOWN: 'set-channel-cooldown',
91
+ SKIP_SLOW_MODE: 'skip-slow-mode',
92
+ SLOW_MODE: 'slow-mode',
93
+ TYPING_EVENTS: 'typing-events',
94
+ UPDATE_ANY_MESSAGE: 'update-any-message',
95
+ UPDATE_CHANNEL: 'update-channel',
96
+ UPDATE_CHANNEL_MEMBERS: 'update-channel-members',
97
+ UPDATE_OWN_MESSAGE: 'update-own-message',
98
+ UPDATE_THREAD: 'update-thread',
99
+ UPLOAD_FILE: 'upload-file',
100
+ };
59
101
  /**
60
102
  * @export
61
103
  */
@@ -95,9 +137,11 @@ const OwnCapability = {
95
137
  SEND_AUDIO: 'send-audio',
96
138
  SEND_VIDEO: 'send-video',
97
139
  START_BROADCAST_CALL: 'start-broadcast-call',
140
+ START_CLOSED_CAPTIONS_CALL: 'start-closed-captions-call',
98
141
  START_RECORD_CALL: 'start-record-call',
99
142
  START_TRANSCRIPTION_CALL: 'start-transcription-call',
100
143
  STOP_BROADCAST_CALL: 'stop-broadcast-call',
144
+ STOP_CLOSED_CAPTIONS_CALL: 'stop-closed-captions-call',
101
145
  STOP_RECORD_CALL: 'stop-record-call',
102
146
  STOP_TRANSCRIPTION_CALL: 'stop-transcription-call',
103
147
  UPDATE_CALL: 'update-call',
@@ -128,6 +172,14 @@ const RecordSettingsRequestQualityEnum = {
128
172
  PORTRAIT_1080X1920: 'portrait-1080x1920',
129
173
  PORTRAIT_1440X2560: 'portrait-1440x2560',
130
174
  };
175
+ /**
176
+ * @export
177
+ */
178
+ const TranscriptionSettingsRequestClosedCaptionModeEnum = {
179
+ AVAILABLE: 'available',
180
+ DISABLED: 'disabled',
181
+ AUTO_ON: 'auto-on',
182
+ };
131
183
  /**
132
184
  * @export
133
185
  */
@@ -136,6 +188,14 @@ const TranscriptionSettingsRequestModeEnum = {
136
188
  DISABLED: 'disabled',
137
189
  AUTO_ON: 'auto-on',
138
190
  };
191
+ /**
192
+ * @export
193
+ */
194
+ const TranscriptionSettingsResponseClosedCaptionModeEnum = {
195
+ AVAILABLE: 'available',
196
+ DISABLED: 'disabled',
197
+ AUTO_ON: 'auto-on',
198
+ };
139
199
  /**
140
200
  * @export
141
201
  */
@@ -1164,23 +1224,33 @@ class VideoLayer$Type extends MessageType {
1164
1224
  */
1165
1225
  const VideoLayer = new VideoLayer$Type();
1166
1226
  // @generated message type with reflection information, may provide speed optimized methods
1167
- class PublishOptions$Type extends MessageType {
1227
+ class SubscribeOption$Type extends MessageType {
1168
1228
  constructor() {
1169
- super('stream.video.sfu.models.PublishOptions', [
1229
+ super('stream.video.sfu.models.SubscribeOption', [
1170
1230
  {
1171
1231
  no: 1,
1232
+ name: 'track_type',
1233
+ kind: 'enum',
1234
+ T: () => [
1235
+ 'stream.video.sfu.models.TrackType',
1236
+ TrackType,
1237
+ 'TRACK_TYPE_',
1238
+ ],
1239
+ },
1240
+ {
1241
+ no: 2,
1172
1242
  name: 'codecs',
1173
1243
  kind: 'message',
1174
1244
  repeat: 1 /*RepeatType.PACKED*/,
1175
- T: () => PublishOption,
1245
+ T: () => Codec,
1176
1246
  },
1177
1247
  ]);
1178
1248
  }
1179
1249
  }
1180
1250
  /**
1181
- * @generated MessageType for protobuf message stream.video.sfu.models.PublishOptions
1251
+ * @generated MessageType for protobuf message stream.video.sfu.models.SubscribeOption
1182
1252
  */
1183
- const PublishOptions = new PublishOptions$Type();
1253
+ const SubscribeOption = new SubscribeOption$Type();
1184
1254
  // @generated message type with reflection information, may provide speed optimized methods
1185
1255
  class PublishOption$Type extends MessageType {
1186
1256
  constructor() {
@@ -1210,6 +1280,13 @@ class PublishOption$Type extends MessageType {
1210
1280
  kind: 'scalar',
1211
1281
  T: 5 /*ScalarType.INT32*/,
1212
1282
  },
1283
+ {
1284
+ no: 7,
1285
+ name: 'video_dimension',
1286
+ kind: 'message',
1287
+ T: () => VideoDimension,
1288
+ },
1289
+ { no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
1213
1290
  ]);
1214
1291
  }
1215
1292
  }
@@ -1222,7 +1299,7 @@ class Codec$Type extends MessageType {
1222
1299
  constructor() {
1223
1300
  super('stream.video.sfu.models.Codec', [
1224
1301
  {
1225
- no: 11,
1302
+ no: 16,
1226
1303
  name: 'payload_type',
1227
1304
  kind: 'scalar',
1228
1305
  T: 13 /*ScalarType.UINT32*/,
@@ -1235,7 +1312,7 @@ class Codec$Type extends MessageType {
1235
1312
  T: 13 /*ScalarType.UINT32*/,
1236
1313
  },
1237
1314
  {
1238
- no: 13,
1315
+ no: 15,
1239
1316
  name: 'encoding_parameters',
1240
1317
  kind: 'scalar',
1241
1318
  T: 9 /*ScalarType.STRING*/,
@@ -1299,6 +1376,13 @@ class TrackInfo$Type extends MessageType {
1299
1376
  { no: 8, name: 'stereo', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1300
1377
  { no: 9, name: 'red', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1301
1378
  { no: 10, name: 'muted', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
1379
+ { no: 11, name: 'codec', kind: 'message', T: () => Codec },
1380
+ {
1381
+ no: 12,
1382
+ name: 'publish_option_id',
1383
+ kind: 'scalar',
1384
+ T: 5 /*ScalarType.INT32*/,
1385
+ },
1302
1386
  ]);
1303
1387
  }
1304
1388
  }
@@ -1572,10 +1656,10 @@ var models = /*#__PURE__*/Object.freeze({
1572
1656
  get PeerType () { return PeerType; },
1573
1657
  Pin: Pin,
1574
1658
  PublishOption: PublishOption,
1575
- PublishOptions: PublishOptions,
1576
1659
  Sdk: Sdk,
1577
1660
  get SdkType () { return SdkType; },
1578
1661
  StreamQuality: StreamQuality,
1662
+ SubscribeOption: SubscribeOption,
1579
1663
  TrackInfo: TrackInfo,
1580
1664
  get TrackType () { return TrackType; },
1581
1665
  get TrackUnpublishReason () { return TrackUnpublishReason; },
@@ -2205,13 +2289,6 @@ class SfuEvent$Type extends MessageType {
2205
2289
  oneof: 'eventPayload',
2206
2290
  T: () => ParticipantMigrationComplete,
2207
2291
  },
2208
- {
2209
- no: 26,
2210
- name: 'codec_negotiation_complete',
2211
- kind: 'message',
2212
- oneof: 'eventPayload',
2213
- T: () => CodecNegotiationComplete,
2214
- },
2215
2292
  {
2216
2293
  no: 27,
2217
2294
  name: 'change_publish_options',
@@ -2232,10 +2309,12 @@ class ChangePublishOptions$Type extends MessageType {
2232
2309
  super('stream.video.sfu.event.ChangePublishOptions', [
2233
2310
  {
2234
2311
  no: 1,
2235
- name: 'publish_option',
2312
+ name: 'publish_options',
2236
2313
  kind: 'message',
2314
+ repeat: 1 /*RepeatType.PACKED*/,
2237
2315
  T: () => PublishOption,
2238
2316
  },
2317
+ { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
2239
2318
  ]);
2240
2319
  }
2241
2320
  }
@@ -2244,15 +2323,15 @@ class ChangePublishOptions$Type extends MessageType {
2244
2323
  */
2245
2324
  const ChangePublishOptions = new ChangePublishOptions$Type();
2246
2325
  // @generated message type with reflection information, may provide speed optimized methods
2247
- class CodecNegotiationComplete$Type extends MessageType {
2326
+ class ChangePublishOptionsComplete$Type extends MessageType {
2248
2327
  constructor() {
2249
- super('stream.video.sfu.event.CodecNegotiationComplete', []);
2328
+ super('stream.video.sfu.event.ChangePublishOptionsComplete', []);
2250
2329
  }
2251
2330
  }
2252
2331
  /**
2253
- * @generated MessageType for protobuf message stream.video.sfu.event.CodecNegotiationComplete
2332
+ * @generated MessageType for protobuf message stream.video.sfu.event.ChangePublishOptionsComplete
2254
2333
  */
2255
- const CodecNegotiationComplete = new CodecNegotiationComplete$Type();
2334
+ const ChangePublishOptionsComplete = new ChangePublishOptionsComplete$Type();
2256
2335
  // @generated message type with reflection information, may provide speed optimized methods
2257
2336
  class ParticipantMigrationComplete$Type extends MessageType {
2258
2337
  constructor() {
@@ -2510,6 +2589,20 @@ class JoinRequest$Type extends MessageType {
2510
2589
  kind: 'message',
2511
2590
  T: () => ReconnectDetails,
2512
2591
  },
2592
+ {
2593
+ no: 9,
2594
+ name: 'preferred_publish_options',
2595
+ kind: 'message',
2596
+ repeat: 1 /*RepeatType.PACKED*/,
2597
+ T: () => PublishOption,
2598
+ },
2599
+ {
2600
+ no: 10,
2601
+ name: 'preferred_subscribe_options',
2602
+ kind: 'message',
2603
+ repeat: 1 /*RepeatType.PACKED*/,
2604
+ T: () => SubscribeOption,
2605
+ },
2513
2606
  ]);
2514
2607
  }
2515
2608
  }
@@ -2617,7 +2710,8 @@ class JoinResponse$Type extends MessageType {
2617
2710
  no: 4,
2618
2711
  name: 'publish_options',
2619
2712
  kind: 'message',
2620
- T: () => PublishOptions,
2713
+ repeat: 1 /*RepeatType.PACKED*/,
2714
+ T: () => PublishOption,
2621
2715
  },
2622
2716
  ]);
2623
2717
  }
@@ -2782,6 +2876,22 @@ class AudioSender$Type extends MessageType {
2782
2876
  constructor() {
2783
2877
  super('stream.video.sfu.event.AudioSender', [
2784
2878
  { no: 2, name: 'codec', kind: 'message', T: () => Codec },
2879
+ {
2880
+ no: 3,
2881
+ name: 'track_type',
2882
+ kind: 'enum',
2883
+ T: () => [
2884
+ 'stream.video.sfu.models.TrackType',
2885
+ TrackType,
2886
+ 'TRACK_TYPE_',
2887
+ ],
2888
+ },
2889
+ {
2890
+ no: 4,
2891
+ name: 'publish_option_id',
2892
+ kind: 'scalar',
2893
+ T: 5 /*ScalarType.INT32*/,
2894
+ },
2785
2895
  ]);
2786
2896
  }
2787
2897
  }
@@ -2834,6 +2944,22 @@ class VideoSender$Type extends MessageType {
2834
2944
  repeat: 1 /*RepeatType.PACKED*/,
2835
2945
  T: () => VideoLayerSetting,
2836
2946
  },
2947
+ {
2948
+ no: 4,
2949
+ name: 'track_type',
2950
+ kind: 'enum',
2951
+ T: () => [
2952
+ 'stream.video.sfu.models.TrackType',
2953
+ TrackType,
2954
+ 'TRACK_TYPE_',
2955
+ ],
2956
+ },
2957
+ {
2958
+ no: 5,
2959
+ name: 'publish_option_id',
2960
+ kind: 'scalar',
2961
+ T: 5 /*ScalarType.INT32*/,
2962
+ },
2837
2963
  ]);
2838
2964
  }
2839
2965
  }
@@ -2930,8 +3056,8 @@ var events = /*#__PURE__*/Object.freeze({
2930
3056
  CallEnded: CallEnded,
2931
3057
  CallGrantsUpdated: CallGrantsUpdated,
2932
3058
  ChangePublishOptions: ChangePublishOptions,
3059
+ ChangePublishOptionsComplete: ChangePublishOptionsComplete,
2933
3060
  ChangePublishQuality: ChangePublishQuality,
2934
- CodecNegotiationComplete: CodecNegotiationComplete,
2935
3061
  ConnectionQualityChanged: ConnectionQualityChanged,
2936
3062
  ConnectionQualityInfo: ConnectionQualityInfo,
2937
3063
  DominantSpeakerChanged: DominantSpeakerChanged,
@@ -3078,11 +3204,18 @@ const withHeaders = (headers) => {
3078
3204
  const withRequestLogger = (logger, level) => {
3079
3205
  return {
3080
3206
  interceptUnary: (next, method, input, options) => {
3081
- logger(level, `Calling SFU RPC method ${method.name}`, {
3082
- input,
3083
- options,
3084
- });
3085
- return next(method, input, options);
3207
+ let invocation;
3208
+ try {
3209
+ invocation = next(method, input, options);
3210
+ }
3211
+ finally {
3212
+ logger(level, `Invoked SFU RPC method ${method.name}`, {
3213
+ request: invocation?.request,
3214
+ headers: invocation?.requestHeaders,
3215
+ response: invocation?.response,
3216
+ });
3217
+ }
3218
+ return invocation;
3086
3219
  },
3087
3220
  };
3088
3221
  };
@@ -3299,374 +3432,98 @@ const retryable = async (rpc, signal) => {
3299
3432
  return result;
3300
3433
  };
3301
3434
 
3302
- const version = "1.13.1";
3303
- const [major, minor, patch] = version.split('.');
3304
- let sdkInfo = {
3305
- type: SdkType.PLAIN_JAVASCRIPT,
3306
- major,
3307
- minor,
3308
- patch,
3309
- };
3310
- let osInfo;
3311
- let deviceInfo;
3312
- let webRtcInfo;
3313
- let deviceState = { oneofKind: undefined };
3314
- const setSdkInfo = (info) => {
3315
- sdkInfo = info;
3316
- };
3317
- const getSdkInfo = () => {
3318
- return sdkInfo;
3319
- };
3320
- const setOSInfo = (info) => {
3321
- osInfo = info;
3322
- };
3323
- const getOSInfo = () => {
3324
- return osInfo;
3325
- };
3326
- const setDeviceInfo = (info) => {
3327
- deviceInfo = info;
3435
+ /**
3436
+ * Returns a generic SDP for the given direction.
3437
+ * We use this SDP to send it as part of our JoinRequest so that the SFU
3438
+ * can use it to determine the client's codec capabilities.
3439
+ *
3440
+ * @param direction the direction of the transceiver.
3441
+ */
3442
+ const getGenericSdp = async (direction) => {
3443
+ const tempPc = new RTCPeerConnection();
3444
+ tempPc.addTransceiver('video', { direction });
3445
+ tempPc.addTransceiver('audio', { direction });
3446
+ const offer = await tempPc.createOffer();
3447
+ const sdp = offer.sdp ?? '';
3448
+ tempPc.getTransceivers().forEach((t) => {
3449
+ t.stop?.();
3450
+ });
3451
+ tempPc.close();
3452
+ return sdp;
3328
3453
  };
3329
- const getDeviceInfo = () => {
3330
- return deviceInfo;
3454
+ /**
3455
+ * Returns whether the codec is an SVC codec.
3456
+ *
3457
+ * @param codecOrMimeType the codec to check.
3458
+ */
3459
+ const isSvcCodec = (codecOrMimeType) => {
3460
+ if (!codecOrMimeType)
3461
+ return false;
3462
+ codecOrMimeType = codecOrMimeType.toLowerCase();
3463
+ return (codecOrMimeType === 'vp9' ||
3464
+ codecOrMimeType === 'av1' ||
3465
+ codecOrMimeType === 'video/vp9' ||
3466
+ codecOrMimeType === 'video/av1');
3331
3467
  };
3332
- const getWebRTCInfo = () => {
3333
- return webRtcInfo;
3468
+
3469
+ const sfuEventKinds = {
3470
+ subscriberOffer: undefined,
3471
+ publisherAnswer: undefined,
3472
+ connectionQualityChanged: undefined,
3473
+ audioLevelChanged: undefined,
3474
+ iceTrickle: undefined,
3475
+ changePublishQuality: undefined,
3476
+ participantJoined: undefined,
3477
+ participantLeft: undefined,
3478
+ dominantSpeakerChanged: undefined,
3479
+ joinResponse: undefined,
3480
+ healthCheckResponse: undefined,
3481
+ trackPublished: undefined,
3482
+ trackUnpublished: undefined,
3483
+ error: undefined,
3484
+ callGrantsUpdated: undefined,
3485
+ goAway: undefined,
3486
+ iceRestart: undefined,
3487
+ pinsUpdated: undefined,
3488
+ callEnded: undefined,
3489
+ participantUpdated: undefined,
3490
+ participantMigrationComplete: undefined,
3491
+ changePublishOptions: undefined,
3334
3492
  };
3335
- const setWebRTCInfo = (info) => {
3336
- webRtcInfo = info;
3493
+ const isSfuEvent = (eventName) => {
3494
+ return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3337
3495
  };
3338
- const setThermalState = (state) => {
3339
- if (!osInfo) {
3340
- deviceState = { oneofKind: undefined };
3341
- return;
3342
- }
3343
- if (osInfo.name === 'android') {
3344
- const thermalState = AndroidThermalState[state] ||
3345
- AndroidThermalState.UNSPECIFIED;
3346
- deviceState = {
3347
- oneofKind: 'android',
3348
- android: {
3349
- thermalState,
3350
- isPowerSaverMode: deviceState?.oneofKind === 'android' &&
3351
- deviceState.android.isPowerSaverMode,
3352
- },
3496
+ class Dispatcher {
3497
+ constructor() {
3498
+ this.logger = getLogger(['Dispatcher']);
3499
+ this.subscribers = {};
3500
+ this.dispatch = (message, logTag = '0') => {
3501
+ const eventKind = message.eventPayload.oneofKind;
3502
+ if (!eventKind)
3503
+ return;
3504
+ const payload = message.eventPayload[eventKind];
3505
+ this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3506
+ const listeners = this.subscribers[eventKind];
3507
+ if (!listeners)
3508
+ return;
3509
+ for (const fn of listeners) {
3510
+ try {
3511
+ fn(payload);
3512
+ }
3513
+ catch (e) {
3514
+ this.logger('warn', 'Listener failed with error', e);
3515
+ }
3516
+ }
3353
3517
  };
3354
- }
3355
- if (osInfo.name.toLowerCase() === 'ios') {
3356
- const thermalState = AppleThermalState[state] ||
3357
- AppleThermalState.UNSPECIFIED;
3358
- deviceState = {
3359
- oneofKind: 'apple',
3360
- apple: {
3361
- thermalState,
3362
- isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
3363
- deviceState.apple.isLowPowerModeEnabled,
3364
- },
3365
- };
3366
- }
3367
- };
3368
- const setPowerState = (powerMode) => {
3369
- if (!osInfo) {
3370
- deviceState = { oneofKind: undefined };
3371
- return;
3372
- }
3373
- if (osInfo.name === 'android') {
3374
- deviceState = {
3375
- oneofKind: 'android',
3376
- android: {
3377
- thermalState: deviceState?.oneofKind === 'android'
3378
- ? deviceState.android.thermalState
3379
- : AndroidThermalState.UNSPECIFIED,
3380
- isPowerSaverMode: powerMode,
3381
- },
3382
- };
3383
- }
3384
- if (osInfo.name.toLowerCase() === 'ios') {
3385
- deviceState = {
3386
- oneofKind: 'apple',
3387
- apple: {
3388
- thermalState: deviceState?.oneofKind === 'apple'
3389
- ? deviceState.apple.thermalState
3390
- : AppleThermalState.UNSPECIFIED,
3391
- isLowPowerModeEnabled: powerMode,
3392
- },
3393
- };
3394
- }
3395
- };
3396
- const getDeviceState = () => {
3397
- return deviceState;
3398
- };
3399
- const getClientDetails = () => {
3400
- if (isReactNative()) {
3401
- // Since RN doesn't support web, sharing browser info is not required
3402
- return {
3403
- sdk: getSdkInfo(),
3404
- os: getOSInfo(),
3405
- device: getDeviceInfo(),
3406
- };
3407
- }
3408
- const userAgent = new UAParser(navigator.userAgent);
3409
- const { browser, os, device, cpu } = userAgent.getResult();
3410
- return {
3411
- sdk: getSdkInfo(),
3412
- browser: {
3413
- name: browser.name || navigator.userAgent,
3414
- version: browser.version || '',
3415
- },
3416
- os: {
3417
- name: os.name || '',
3418
- version: os.version || '',
3419
- architecture: cpu.architecture || '',
3420
- },
3421
- device: {
3422
- name: [device.vendor, device.model, device.type]
3423
- .filter(Boolean)
3424
- .join(' '),
3425
- version: '',
3426
- },
3427
- };
3428
- };
3429
-
3430
- /**
3431
- * Checks whether the current browser is Safari.
3432
- */
3433
- const isSafari = () => {
3434
- if (typeof navigator === 'undefined')
3435
- return false;
3436
- return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
3437
- };
3438
- /**
3439
- * Checks whether the current browser is Firefox.
3440
- */
3441
- const isFirefox = () => {
3442
- if (typeof navigator === 'undefined')
3443
- return false;
3444
- return navigator.userAgent?.includes('Firefox');
3445
- };
3446
- /**
3447
- * Checks whether the current browser is Google Chrome.
3448
- */
3449
- const isChrome = () => {
3450
- if (typeof navigator === 'undefined')
3451
- return false;
3452
- return navigator.userAgent?.includes('Chrome');
3453
- };
3454
-
3455
- var browsers = /*#__PURE__*/Object.freeze({
3456
- __proto__: null,
3457
- isChrome: isChrome,
3458
- isFirefox: isFirefox,
3459
- isSafari: isSafari
3460
- });
3461
-
3462
- /**
3463
- * Returns back a list of sorted codecs, with the preferred codec first.
3464
- *
3465
- * @param kind the kind of codec to get.
3466
- * @param preferredCodec the codec to prioritize (vp8, h264, vp9, av1...).
3467
- * @param codecToRemove the codec to exclude from the list.
3468
- * @param codecPreferencesSource the source of the codec preferences.
3469
- */
3470
- const getPreferredCodecs = (kind, preferredCodec, codecToRemove, codecPreferencesSource) => {
3471
- const source = codecPreferencesSource === 'receiver' ? RTCRtpReceiver : RTCRtpSender;
3472
- if (!('getCapabilities' in source))
3473
- return;
3474
- const capabilities = source.getCapabilities(kind);
3475
- if (!capabilities)
3476
- return;
3477
- const preferred = [];
3478
- const partiallyPreferred = [];
3479
- const unpreferred = [];
3480
- const preferredCodecMimeType = `${kind}/${preferredCodec.toLowerCase()}`;
3481
- const codecToRemoveMimeType = codecToRemove && `${kind}/${codecToRemove.toLowerCase()}`;
3482
- for (const codec of capabilities.codecs) {
3483
- const codecMimeType = codec.mimeType.toLowerCase();
3484
- const shouldRemoveCodec = codecMimeType === codecToRemoveMimeType;
3485
- if (shouldRemoveCodec)
3486
- continue; // skip this codec
3487
- const isPreferredCodec = codecMimeType === preferredCodecMimeType;
3488
- if (!isPreferredCodec) {
3489
- unpreferred.push(codec);
3490
- continue;
3491
- }
3492
- // h264 is a special case, we want to prioritize the baseline codec with
3493
- // profile-level-id is 42e01f and packetization-mode=0 for maximum
3494
- // cross-browser compatibility.
3495
- // this branch covers the other cases, such as vp8.
3496
- if (codecMimeType !== 'video/h264') {
3497
- preferred.push(codec);
3498
- continue;
3499
- }
3500
- const sdpFmtpLine = codec.sdpFmtpLine;
3501
- if (!sdpFmtpLine || !sdpFmtpLine.includes('profile-level-id=42')) {
3502
- // this is not the baseline h264 codec, prioritize it lower
3503
- partiallyPreferred.push(codec);
3504
- continue;
3505
- }
3506
- if (sdpFmtpLine.includes('packetization-mode=1')) {
3507
- preferred.unshift(codec);
3508
- }
3509
- else {
3510
- preferred.push(codec);
3511
- }
3512
- }
3513
- // return a sorted list of codecs, with the preferred codecs first
3514
- return [...preferred, ...partiallyPreferred, ...unpreferred];
3515
- };
3516
- /**
3517
- * Returns a generic SDP for the given direction.
3518
- * We use this SDP to send it as part of our JoinRequest so that the SFU
3519
- * can use it to determine client's codec capabilities.
3520
- *
3521
- * @param direction the direction of the transceiver.
3522
- */
3523
- const getGenericSdp = async (direction) => {
3524
- const tempPc = new RTCPeerConnection();
3525
- tempPc.addTransceiver('video', { direction });
3526
- tempPc.addTransceiver('audio', { direction });
3527
- const offer = await tempPc.createOffer();
3528
- const sdp = offer.sdp ?? '';
3529
- tempPc.getTransceivers().forEach((t) => {
3530
- t.stop?.();
3531
- });
3532
- tempPc.close();
3533
- return sdp;
3534
- };
3535
- /**
3536
- * Returns the optimal video codec for the device.
3537
- */
3538
- const getOptimalVideoCodec = (preferredCodec) => {
3539
- if (isReactNative()) {
3540
- const os = getOSInfo()?.name.toLowerCase();
3541
- if (os === 'android')
3542
- return preferredOr(preferredCodec, 'vp8');
3543
- if (os === 'ios' || os === 'ipados') {
3544
- return supportsH264Baseline() ? 'h264' : 'vp8';
3545
- }
3546
- return preferredOr(preferredCodec, 'h264');
3547
- }
3548
- if (isSafari())
3549
- return 'h264';
3550
- if (isFirefox())
3551
- return 'vp8';
3552
- return preferredOr(preferredCodec, 'vp8');
3553
- };
3554
- /**
3555
- * Determines if the platform supports the preferred codec.
3556
- * If not, it returns the fallback codec.
3557
- */
3558
- const preferredOr = (codec, fallback) => {
3559
- if (!codec)
3560
- return fallback;
3561
- if (!('getCapabilities' in RTCRtpSender))
3562
- return fallback;
3563
- const capabilities = RTCRtpSender.getCapabilities('video');
3564
- if (!capabilities)
3565
- return fallback;
3566
- // Safari and Firefox do not have a good support encoding to SVC codecs,
3567
- // so we disable it for them.
3568
- if (isSvcCodec(codec) && (isSafari() || isFirefox()))
3569
- return fallback;
3570
- const { codecs } = capabilities;
3571
- const codecMimeType = `video/${codec}`.toLowerCase();
3572
- return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
3573
- ? codec
3574
- : fallback;
3575
- };
3576
- /**
3577
- * Returns whether the platform supports the H264 baseline codec.
3578
- */
3579
- const supportsH264Baseline = () => {
3580
- if (!('getCapabilities' in RTCRtpSender))
3581
- return false;
3582
- const capabilities = RTCRtpSender.getCapabilities('video');
3583
- if (!capabilities)
3584
- return false;
3585
- return capabilities.codecs.some((c) => c.mimeType.toLowerCase() === 'video/h264' &&
3586
- c.sdpFmtpLine?.includes('profile-level-id=42e01f'));
3587
- };
3588
- /**
3589
- * Returns whether the codec is an SVC codec.
3590
- *
3591
- * @param codecOrMimeType the codec to check.
3592
- */
3593
- const isSvcCodec = (codecOrMimeType) => {
3594
- if (!codecOrMimeType)
3595
- return false;
3596
- codecOrMimeType = codecOrMimeType.toLowerCase();
3597
- return (codecOrMimeType === 'vp9' ||
3598
- codecOrMimeType === 'av1' ||
3599
- codecOrMimeType === 'video/vp9' ||
3600
- codecOrMimeType === 'video/av1');
3601
- };
3602
-
3603
- const sfuEventKinds = {
3604
- subscriberOffer: undefined,
3605
- publisherAnswer: undefined,
3606
- connectionQualityChanged: undefined,
3607
- audioLevelChanged: undefined,
3608
- iceTrickle: undefined,
3609
- changePublishQuality: undefined,
3610
- participantJoined: undefined,
3611
- participantLeft: undefined,
3612
- dominantSpeakerChanged: undefined,
3613
- joinResponse: undefined,
3614
- healthCheckResponse: undefined,
3615
- trackPublished: undefined,
3616
- trackUnpublished: undefined,
3617
- error: undefined,
3618
- callGrantsUpdated: undefined,
3619
- goAway: undefined,
3620
- iceRestart: undefined,
3621
- pinsUpdated: undefined,
3622
- callEnded: undefined,
3623
- participantUpdated: undefined,
3624
- participantMigrationComplete: undefined,
3625
- codecNegotiationComplete: undefined,
3626
- changePublishOptions: undefined,
3627
- };
3628
- const isSfuEvent = (eventName) => {
3629
- return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
3630
- };
3631
- class Dispatcher {
3632
- constructor() {
3633
- this.logger = getLogger(['Dispatcher']);
3634
- this.subscribers = {};
3635
- this.dispatch = (message, logTag = '0') => {
3636
- const eventKind = message.eventPayload.oneofKind;
3637
- if (!eventKind)
3638
- return;
3639
- const payload = message.eventPayload[eventKind];
3640
- this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
3641
- const listeners = this.subscribers[eventKind];
3642
- if (!listeners)
3643
- return;
3644
- for (const fn of listeners) {
3645
- try {
3646
- fn(payload);
3647
- }
3648
- catch (e) {
3649
- this.logger('warn', 'Listener failed with error', e);
3650
- }
3651
- }
3652
- };
3653
- this.on = (eventName, fn) => {
3654
- var _a;
3655
- ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3656
- return () => {
3657
- this.off(eventName, fn);
3658
- };
3659
- };
3660
- this.off = (eventName, fn) => {
3661
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3662
- };
3663
- this.offAll = (eventName) => {
3664
- if (eventName) {
3665
- this.subscribers[eventName] = [];
3666
- }
3667
- else {
3668
- this.subscribers = {};
3669
- }
3518
+ this.on = (eventName, fn) => {
3519
+ var _a;
3520
+ ((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
3521
+ return () => {
3522
+ this.off(eventName, fn);
3523
+ };
3524
+ };
3525
+ this.off = (eventName, fn) => {
3526
+ this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
3670
3527
  };
3671
3528
  }
3672
3529
  }
@@ -3680,284 +3537,34 @@ class IceTrickleBuffer {
3680
3537
  this.subscriberCandidates = new ReplaySubject();
3681
3538
  this.publisherCandidates = new ReplaySubject();
3682
3539
  this.push = (iceTrickle) => {
3540
+ const iceCandidate = toIceCandidate(iceTrickle);
3541
+ if (!iceCandidate)
3542
+ return;
3683
3543
  if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
3684
- this.subscriberCandidates.next(iceTrickle);
3544
+ this.subscriberCandidates.next(iceCandidate);
3685
3545
  }
3686
3546
  else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
3687
- this.publisherCandidates.next(iceTrickle);
3547
+ this.publisherCandidates.next(iceCandidate);
3688
3548
  }
3689
3549
  else {
3690
3550
  const logger = getLogger(['sfu-client']);
3691
3551
  logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
3692
3552
  }
3693
3553
  };
3694
- }
3695
- }
3696
-
3697
- function getIceCandidate(candidate) {
3698
- if (!candidate.usernameFragment) {
3699
- // react-native-webrtc doesn't include usernameFragment in the candidate
3700
- const splittedCandidate = candidate.candidate.split(' ');
3701
- const ufragIndex = splittedCandidate.findIndex((s) => s === 'ufrag') + 1;
3702
- const usernameFragment = splittedCandidate[ufragIndex];
3703
- return JSON.stringify({ ...candidate, usernameFragment });
3704
- }
3705
- else {
3706
- return JSON.stringify(candidate.toJSON());
3707
- }
3708
- }
3709
-
3710
- const bitrateLookupTable = {
3711
- h264: {
3712
- 2160: 5000000,
3713
- 1440: 3000000,
3714
- 1080: 2000000,
3715
- 720: 1250000,
3716
- 540: 750000,
3717
- 360: 400000,
3718
- default: 1250000,
3719
- },
3720
- vp8: {
3721
- 2160: 5000000,
3722
- 1440: 2750000,
3723
- 1080: 2000000,
3724
- 720: 1250000,
3725
- 540: 600000,
3726
- 360: 350000,
3727
- default: 1250000,
3728
- },
3729
- vp9: {
3730
- 2160: 3000000,
3731
- 1440: 2000000,
3732
- 1080: 1500000,
3733
- 720: 1250000,
3734
- 540: 500000,
3735
- 360: 275000,
3736
- default: 1250000,
3737
- },
3738
- av1: {
3739
- 2160: 2000000,
3740
- 1440: 1550000,
3741
- 1080: 1000000,
3742
- 720: 600000,
3743
- 540: 350000,
3744
- 360: 200000,
3745
- default: 600000,
3746
- },
3747
- };
3748
- const getOptimalBitrate = (codec, frameHeight) => {
3749
- const codecLookup = bitrateLookupTable[codec];
3750
- if (!codecLookup)
3751
- throw new Error(`Unknown codec: ${codec}`);
3752
- let bitrate = codecLookup[frameHeight];
3753
- if (!bitrate) {
3754
- const keys = Object.keys(codecLookup).map(Number);
3755
- const nearest = keys.reduce((a, b) => Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a);
3756
- bitrate = codecLookup[nearest];
3757
- }
3758
- return bitrate ?? codecLookup.default;
3759
- };
3760
-
3761
- const DEFAULT_BITRATE = 1250000;
3762
- const defaultTargetResolution = {
3763
- bitrate: DEFAULT_BITRATE,
3764
- width: 1280,
3765
- height: 720,
3766
- };
3767
- const defaultBitratePerRid = {
3768
- q: 300000,
3769
- h: 750000,
3770
- f: DEFAULT_BITRATE,
3771
- };
3772
- /**
3773
- * In SVC, we need to send only one video encoding (layer).
3774
- * this layer will have the additional spatial and temporal layers
3775
- * defined via the scalabilityMode property.
3776
- *
3777
- * @param layers the layers to process.
3778
- */
3779
- const toSvcEncodings = (layers) => {
3780
- // we take the `f` layer, and we rename it to `q`.
3781
- return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
3782
- };
3783
- /**
3784
- * Converts the rid to a video quality.
3785
- */
3786
- const ridToVideoQuality = (rid) => {
3787
- return rid === 'q'
3788
- ? VideoQuality.LOW_UNSPECIFIED
3789
- : rid === 'h'
3790
- ? VideoQuality.MID
3791
- : VideoQuality.HIGH; // default to HIGH
3792
- };
3793
- /**
3794
- * Determines the most optimal video layers for simulcasting
3795
- * for the given track.
3796
- *
3797
- * @param videoTrack the video track to find optimal layers for.
3798
- * @param targetResolution the expected target resolution.
3799
- * @param codecInUse the codec in use.
3800
- * @param publishOptions the publish options for the track.
3801
- */
3802
- const findOptimalVideoLayers = (videoTrack, targetResolution = defaultTargetResolution, codecInUse, publishOptions) => {
3803
- const optimalVideoLayers = [];
3804
- const settings = videoTrack.getSettings();
3805
- const { width = 0, height = 0 } = settings;
3806
- const { scalabilityMode, bitrateDownscaleFactor = 2, maxSimulcastLayers = 3, } = publishOptions || {};
3807
- const maxBitrate = getComputedMaxBitrate(targetResolution, width, height, codecInUse, publishOptions);
3808
- let downscaleFactor = 1;
3809
- let bitrateFactor = 1;
3810
- const svcCodec = isSvcCodec(codecInUse);
3811
- const totalLayers = svcCodec ? 3 : Math.min(3, maxSimulcastLayers);
3812
- for (const rid of ['f', 'h', 'q'].slice(0, totalLayers)) {
3813
- const layer = {
3814
- active: true,
3815
- rid,
3816
- width: Math.round(width / downscaleFactor),
3817
- height: Math.round(height / downscaleFactor),
3818
- maxBitrate: Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
3819
- maxFramerate: 30,
3554
+ this.dispose = () => {
3555
+ this.subscriberCandidates.complete();
3556
+ this.publisherCandidates.complete();
3820
3557
  };
3821
- if (svcCodec) {
3822
- // for SVC codecs, we need to set the scalability mode, and the
3823
- // codec will handle the rest (layers, temporal layers, etc.)
3824
- layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
3825
- }
3826
- else {
3827
- // for non-SVC codecs, we need to downscale proportionally (simulcast)
3828
- layer.scaleResolutionDownBy = downscaleFactor;
3829
- }
3830
- downscaleFactor *= 2;
3831
- bitrateFactor *= bitrateDownscaleFactor;
3832
- // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
3833
- // when deciding which layer to disable when CPU or bandwidth is constrained.
3834
- // Encodings should be ordered in increasing spatial resolution order.
3835
- optimalVideoLayers.unshift(layer);
3836
- }
3837
- // for simplicity, we start with all layers enabled, then this function
3838
- // will clear/reassign the layers that are not needed
3839
- return withSimulcastConstraints(settings, optimalVideoLayers);
3840
- };
3841
- /**
3842
- * Computes the maximum bitrate for a given resolution.
3843
- * If the current resolution is lower than the target resolution,
3844
- * we want to proportionally reduce the target bitrate.
3845
- * If the current resolution is higher than the target resolution,
3846
- * we want to use the target bitrate.
3847
- *
3848
- * @param targetResolution the target resolution.
3849
- * @param currentWidth the current width of the track.
3850
- * @param currentHeight the current height of the track.
3851
- * @param codecInUse the codec in use.
3852
- * @param publishOptions the publish options.
3853
- */
3854
- const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, codecInUse, publishOptions) => {
3855
- // if the current resolution is lower than the target resolution,
3856
- // we want to proportionally reduce the target bitrate
3857
- const { width: targetWidth, height: targetHeight, bitrate: targetBitrate, } = targetResolution;
3858
- const { preferredBitrate } = publishOptions || {};
3859
- const frameHeight = currentWidth > currentHeight ? currentHeight : currentWidth;
3860
- const bitrate = preferredBitrate ||
3861
- (codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
3862
- if (currentWidth < targetWidth || currentHeight < targetHeight) {
3863
- const currentPixels = currentWidth * currentHeight;
3864
- const targetPixels = targetWidth * targetHeight;
3865
- const reductionFactor = currentPixels / targetPixels;
3866
- return Math.round(bitrate * reductionFactor);
3867
- }
3868
- return bitrate;
3869
- };
3870
- /**
3871
- * Browsers have different simulcast constraints for different video resolutions.
3872
- *
3873
- * This function modifies the provided list of video layers according to the
3874
- * current implementation of simulcast constraints in the Chromium based browsers.
3875
- *
3876
- * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
3877
- */
3878
- const withSimulcastConstraints = (settings, optimalVideoLayers) => {
3879
- let layers;
3880
- const size = Math.max(settings.width || 0, settings.height || 0);
3881
- if (size <= 320) {
3882
- // provide only one layer 320x240 (q), the one with the highest quality
3883
- layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
3884
- }
3885
- else if (size <= 640) {
3886
- // provide two layers, 160x120 (q) and 640x480 (h)
3887
- layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
3888
- }
3889
- else {
3890
- // provide three layers for sizes > 640x480
3891
- layers = optimalVideoLayers;
3892
- }
3893
- const ridMapping = ['q', 'h', 'f'];
3894
- return layers.map((layer, index) => ({
3895
- ...layer,
3896
- rid: ridMapping[index], // reassign rid
3897
- }));
3898
- };
3899
- const findOptimalScreenSharingLayers = (videoTrack, publishOptions, defaultMaxBitrate = 3000000) => {
3900
- const { screenShareSettings: preferences } = publishOptions || {};
3901
- const settings = videoTrack.getSettings();
3902
- return [
3903
- {
3904
- active: true,
3905
- rid: 'q', // single track, start from 'q'
3906
- width: settings.width || 0,
3907
- height: settings.height || 0,
3908
- scaleResolutionDownBy: 1,
3909
- maxBitrate: preferences?.maxBitrate ?? defaultMaxBitrate,
3910
- maxFramerate: preferences?.maxFramerate ?? 30,
3911
- },
3912
- ];
3913
- };
3914
-
3915
- const ensureExhausted = (x, message) => {
3916
- getLogger(['helpers'])('warn', message, x);
3917
- };
3918
-
3919
- const trackTypeToParticipantStreamKey = (trackType) => {
3920
- switch (trackType) {
3921
- case TrackType.SCREEN_SHARE:
3922
- return 'screenShareStream';
3923
- case TrackType.SCREEN_SHARE_AUDIO:
3924
- return 'screenShareAudioStream';
3925
- case TrackType.VIDEO:
3926
- return 'videoStream';
3927
- case TrackType.AUDIO:
3928
- return 'audioStream';
3929
- case TrackType.UNSPECIFIED:
3930
- throw new Error('Track type is unspecified');
3931
- default:
3932
- ensureExhausted(trackType, 'Unknown track type');
3933
3558
  }
3934
- };
3935
- const muteTypeToTrackType = (muteType) => {
3936
- switch (muteType) {
3937
- case 'audio':
3938
- return TrackType.AUDIO;
3939
- case 'video':
3940
- return TrackType.VIDEO;
3941
- case 'screenshare':
3942
- return TrackType.SCREEN_SHARE;
3943
- case 'screenshare_audio':
3944
- return TrackType.SCREEN_SHARE_AUDIO;
3945
- default:
3946
- ensureExhausted(muteType, 'Unknown mute type');
3559
+ }
3560
+ const toIceCandidate = (iceTrickle) => {
3561
+ try {
3562
+ return JSON.parse(iceTrickle.iceCandidate);
3947
3563
  }
3948
- };
3949
- const toTrackType = (trackType) => {
3950
- switch (trackType) {
3951
- case 'TRACK_TYPE_AUDIO':
3952
- return TrackType.AUDIO;
3953
- case 'TRACK_TYPE_VIDEO':
3954
- return TrackType.VIDEO;
3955
- case 'TRACK_TYPE_SCREEN_SHARE':
3956
- return TrackType.SCREEN_SHARE;
3957
- case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
3958
- return TrackType.SCREEN_SHARE_AUDIO;
3959
- default:
3960
- return undefined;
3564
+ catch (e) {
3565
+ const logger = getLogger(['sfu-client']);
3566
+ logger('error', `Failed to parse ICE Trickle`, e, iceTrickle);
3567
+ return undefined;
3961
3568
  }
3962
3569
  };
3963
3570
 
@@ -4101,6 +3708,24 @@ const setCurrentValue = (subject, update) => {
4101
3708
  subject.next(next);
4102
3709
  return next;
4103
3710
  };
3711
+ /**
3712
+ * Updates the value of the provided Subject and returns the previous value
3713
+ * and a function to roll back the update.
3714
+ * This is useful when you want to optimistically update a value
3715
+ * and roll back the update if an error occurs.
3716
+ *
3717
+ * @param subject the subject to update.
3718
+ * @param update the update to apply to the subject.
3719
+ */
3720
+ const updateValue = (subject, update) => {
3721
+ const lastValue = subject.getValue();
3722
+ const value = setCurrentValue(subject, update);
3723
+ return {
3724
+ lastValue,
3725
+ value,
3726
+ rollback: () => setCurrentValue(subject, lastValue),
3727
+ };
3728
+ };
4104
3729
  /**
4105
3730
  * Creates a subscription and returns a function to unsubscribe.
4106
3731
  *
@@ -4134,7 +3759,8 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4134
3759
  createSafeAsyncSubscription: createSafeAsyncSubscription,
4135
3760
  createSubscription: createSubscription,
4136
3761
  getCurrentValue: getCurrentValue,
4137
- setCurrentValue: setCurrentValue
3762
+ setCurrentValue: setCurrentValue,
3763
+ updateValue: updateValue
4138
3764
  });
4139
3765
 
4140
3766
  /**
@@ -4581,6 +4207,7 @@ class CallState {
4581
4207
  this.sessionSubject = new BehaviorSubject(undefined);
4582
4208
  this.settingsSubject = new BehaviorSubject(undefined);
4583
4209
  this.transcribingSubject = new BehaviorSubject(false);
4210
+ this.captioningSubject = new BehaviorSubject(false);
4584
4211
  this.endedBySubject = new BehaviorSubject(undefined);
4585
4212
  this.thumbnailsSubject = new BehaviorSubject(undefined);
4586
4213
  this.membersSubject = new BehaviorSubject([]);
@@ -4591,6 +4218,7 @@ class CallState {
4591
4218
  this.anonymousParticipantCountSubject = new BehaviorSubject(0);
4592
4219
  this.participantsSubject = new BehaviorSubject([]);
4593
4220
  this.callStatsReportSubject = new BehaviorSubject(undefined);
4221
+ this.closedCaptionsSubject = new BehaviorSubject([]);
4594
4222
  // These are tracks that were delivered to the Subscriber's onTrack event
4595
4223
  // that we couldn't associate with a participant yet.
4596
4224
  // This happens when the participantJoined event hasn't been received yet.
@@ -4599,10 +4227,18 @@ class CallState {
4599
4227
  this.logger = getLogger(['CallState']);
4600
4228
  /**
4601
4229
  * A list of comparators that are used to sort the participants.
4602
- *
4603
- * @private
4604
4230
  */
4605
4231
  this.sortParticipantsBy = defaultSortPreset;
4232
+ this.closedCaptionsTasks = new Map();
4233
+ /**
4234
+ * Runs the cleanup tasks.
4235
+ */
4236
+ this.dispose = () => {
4237
+ for (const [ccKey, taskId] of this.closedCaptionsTasks.entries()) {
4238
+ clearTimeout(taskId);
4239
+ this.closedCaptionsTasks.delete(ccKey);
4240
+ }
4241
+ };
4606
4242
  /**
4607
4243
  * Sets the list of criteria that are used to sort the participants.
4608
4244
  * To disable sorting, you can pass `noopComparator()`.
@@ -4651,6 +4287,15 @@ class CallState {
4651
4287
  this.setStartedAt = (startedAt) => {
4652
4288
  return this.setCurrentValue(this.startedAtSubject, startedAt);
4653
4289
  };
4290
+ /**
4291
+ * Sets the closed captioning state of the current call.
4292
+ *
4293
+ * @internal
4294
+ * @param captioning the closed captioning state.
4295
+ */
4296
+ this.setCaptioning = (captioning) => {
4297
+ return updateValue(this.captioningSubject, captioning);
4298
+ };
4654
4299
  /**
4655
4300
  * Sets the number of anonymous participants in the current call.
4656
4301
  *
@@ -4749,7 +4394,6 @@ class CallState {
4749
4394
  }
4750
4395
  const thePatch = typeof patch === 'function' ? patch(participant) : patch;
4751
4396
  const updatedParticipant = {
4752
- // FIXME OL: this is not a deep merge, we might want to revisit this
4753
4397
  ...participant,
4754
4398
  ...thePatch,
4755
4399
  };
@@ -4811,7 +4455,6 @@ class CallState {
4811
4455
  *
4812
4456
  * @param trackType the kind of subscription to update.
4813
4457
  * @param changes the list of subscription changes to do.
4814
- * @param type the debounce type to use for the update.
4815
4458
  */
4816
4459
  this.updateParticipantTracks = (trackType, changes) => {
4817
4460
  return this.updateParticipants(Object.entries(changes).reduce((acc, [sessionId, change]) => {
@@ -4914,6 +4557,14 @@ class CallState {
4914
4557
  }
4915
4558
  return orphans;
4916
4559
  };
4560
+ /**
4561
+ * Updates the closed captions settings.
4562
+ *
4563
+ * @param config the new closed captions settings.
4564
+ */
4565
+ this.updateClosedCaptionSettings = (config) => {
4566
+ this.closedCaptionsSettings = { ...this.closedCaptionsSettings, ...config };
4567
+ };
4917
4568
  /**
4918
4569
  * Updates the call state with the data received from the server.
4919
4570
  *
@@ -4937,6 +4588,7 @@ class CallState {
4937
4588
  this.updateParticipantCountFromSession(s);
4938
4589
  this.setCurrentValue(this.settingsSubject, call.settings);
4939
4590
  this.setCurrentValue(this.transcribingSubject, call.transcribing);
4591
+ this.setCurrentValue(this.captioningSubject, call.captioning);
4940
4592
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
4941
4593
  };
4942
4594
  /**
@@ -5128,6 +4780,35 @@ class CallState {
5128
4780
  this.setCurrentValue(this.ownCapabilitiesSubject, event.own_capabilities);
5129
4781
  }
5130
4782
  };
4783
+ this.updateFromClosedCaptions = (event) => {
4784
+ this.setCurrentValue(this.closedCaptionsSubject, (queue) => {
4785
+ const { closed_caption } = event;
4786
+ const keyOf = (c) => `${c.speaker_id}/${c.start_time}`;
4787
+ const currentKey = keyOf(closed_caption);
4788
+ const duplicate = queue.some((caption) => keyOf(caption) === currentKey);
4789
+ if (duplicate)
4790
+ return queue;
4791
+ const nextQueue = [...queue, closed_caption];
4792
+ const { visibilityDurationMs = 2700, maxVisibleCaptions = 2 } = this.closedCaptionsSettings || {};
4793
+ // schedule the removal of the closed caption after the retention time
4794
+ if (visibilityDurationMs > 0) {
4795
+ const taskId = setTimeout(() => {
4796
+ this.setCurrentValue(this.closedCaptionsSubject, (captions) => captions.filter((caption) => caption !== closed_caption));
4797
+ this.closedCaptionsTasks.delete(currentKey);
4798
+ }, visibilityDurationMs);
4799
+ this.closedCaptionsTasks.set(currentKey, taskId);
4800
+ // cancel the cleanup tasks for the closed captions that are no longer in the queue
4801
+ for (let i = 0; i < nextQueue.length - maxVisibleCaptions; i++) {
4802
+ const key = keyOf(nextQueue[i]);
4803
+ const task = this.closedCaptionsTasks.get(key);
4804
+ clearTimeout(task);
4805
+ this.closedCaptionsTasks.delete(key);
4806
+ }
4807
+ }
4808
+ // trim the queue
4809
+ return nextQueue.slice(-maxVisibleCaptions);
4810
+ });
4811
+ };
5131
4812
  this.participants$ = this.participantsSubject.asObservable().pipe(
5132
4813
  // maintain stable-sort by mutating the participants stored
5133
4814
  // in the original subject
@@ -5154,6 +4835,7 @@ class CallState {
5154
4835
  this.settings$ = this.settingsSubject.asObservable();
5155
4836
  this.endedBy$ = this.endedBySubject.asObservable();
5156
4837
  this.thumbnails$ = this.thumbnailsSubject.asObservable();
4838
+ this.closedCaptions$ = this.closedCaptionsSubject.asObservable();
5157
4839
  /**
5158
4840
  * Performs shallow comparison of two arrays.
5159
4841
  * Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
@@ -5183,9 +4865,9 @@ class CallState {
5183
4865
  this.participantCount$ = duc(this.participantCountSubject);
5184
4866
  this.recording$ = duc(this.recordingSubject);
5185
4867
  this.transcribing$ = duc(this.transcribingSubject);
4868
+ this.captioning$ = duc(this.captioningSubject);
5186
4869
  this.eventHandlers = {
5187
4870
  // these events are not updating the call state:
5188
- 'call.closed_caption': undefined,
5189
4871
  'call.deleted': undefined,
5190
4872
  'call.permission_request': undefined,
5191
4873
  'call.recording_ready': undefined,
@@ -5206,6 +4888,16 @@ class CallState {
5206
4888
  // events that update call state:
5207
4889
  'call.accepted': (e) => this.updateFromCallResponse(e.call),
5208
4890
  'call.blocked_user': this.blockUser,
4891
+ 'call.closed_caption': this.updateFromClosedCaptions,
4892
+ 'call.closed_captions_failed': () => {
4893
+ this.setCurrentValue(this.captioningSubject, false);
4894
+ },
4895
+ 'call.closed_captions_started': () => {
4896
+ this.setCurrentValue(this.captioningSubject, true);
4897
+ },
4898
+ 'call.closed_captions_stopped': () => {
4899
+ this.setCurrentValue(this.captioningSubject, false);
4900
+ },
5209
4901
  'call.created': (e) => this.updateFromCallResponse(e.call),
5210
4902
  'call.ended': (e) => {
5211
4903
  this.updateFromCallResponse(e.call);
@@ -5263,6 +4955,12 @@ class CallState {
5263
4955
  get startedAt() {
5264
4956
  return this.getCurrentValue(this.startedAt$);
5265
4957
  }
4958
+ /**
4959
+ * Returns whether closed captions are enabled in the current call.
4960
+ */
4961
+ get captioning() {
4962
+ return this.getCurrentValue(this.captioning$);
4963
+ }
5266
4964
  /**
5267
4965
  * The server-side counted number of anonymous participants connected to the current call.
5268
4966
  * This number includes the anonymous participants as well.
@@ -5426,200 +5124,454 @@ class CallState {
5426
5124
  get thumbnails() {
5427
5125
  return this.getCurrentValue(this.thumbnails$);
5428
5126
  }
5127
+ /**
5128
+ * Returns the current queue of closed captions.
5129
+ */
5130
+ get closedCaptions() {
5131
+ return this.getCurrentValue(this.closedCaptions$);
5132
+ }
5429
5133
  }
5430
5134
 
5431
- const getRtpMap = (line) => {
5432
- // Example: a=rtpmap:110 opus/48000/2
5433
- const rtpRegex = /^a=rtpmap:(\d*) ([\w\-.]*)(?:\s*\/(\d*)(?:\s*\/(\S*))?)?/;
5434
- // The first captured group is the payload type number, the second captured group is the encoding name, the third captured group is the clock rate, and the fourth captured group is any additional parameters.
5435
- const rtpMatch = rtpRegex.exec(line);
5436
- if (rtpMatch) {
5437
- return {
5438
- original: rtpMatch[0],
5439
- payload: rtpMatch[1],
5440
- codec: rtpMatch[2],
5135
+ /**
5136
+ * A base class for the `Publisher` and `Subscriber` classes.
5137
+ * @internal
5138
+ */
5139
+ class BasePeerConnection {
5140
+ /**
5141
+ * Constructs a new `BasePeerConnection` instance.
5142
+ */
5143
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onUnrecoverableError, logTag, }) {
5144
+ this.isIceRestarting = false;
5145
+ this.subscriptions = [];
5146
+ /**
5147
+ * Disposes the `RTCPeerConnection` instance.
5148
+ */
5149
+ this.dispose = () => {
5150
+ this.detachEventHandlers();
5151
+ this.pc.close();
5152
+ };
5153
+ /**
5154
+ * Handles events synchronously.
5155
+ * Consecutive events are queued and executed one after the other.
5156
+ */
5157
+ this.on = (event, fn) => {
5158
+ this.subscriptions.push(this.dispatcher.on(event, (e) => {
5159
+ withoutConcurrency(`pc.${event}`, async () => fn(e)).catch((err) => {
5160
+ this.logger('warn', `Error handling ${event}`, err);
5161
+ });
5162
+ }));
5163
+ };
5164
+ /**
5165
+ * Appends the trickled ICE candidates to the `RTCPeerConnection`.
5166
+ */
5167
+ this.addTrickledIceCandidates = () => {
5168
+ const { iceTrickleBuffer } = this.sfuClient;
5169
+ const observable = this.peerType === PeerType.SUBSCRIBER
5170
+ ? iceTrickleBuffer.subscriberCandidates
5171
+ : iceTrickleBuffer.publisherCandidates;
5172
+ this.unsubscribeIceTrickle?.();
5173
+ this.unsubscribeIceTrickle = createSafeAsyncSubscription(observable, async (candidate) => {
5174
+ return this.pc.addIceCandidate(candidate).catch((e) => {
5175
+ this.logger('warn', `ICE candidate error`, e, candidate);
5176
+ });
5177
+ });
5178
+ };
5179
+ /**
5180
+ * Sets the SFU client to use.
5181
+ *
5182
+ * @param sfuClient the SFU client to use.
5183
+ */
5184
+ this.setSfuClient = (sfuClient) => {
5185
+ this.sfuClient = sfuClient;
5186
+ };
5187
+ /**
5188
+ * Returns the result of the `RTCPeerConnection.getStats()` method
5189
+ * @param selector an optional `MediaStreamTrack` to get the stats for.
5190
+ */
5191
+ this.getStats = (selector) => {
5192
+ return this.pc.getStats(selector);
5193
+ };
5194
+ /**
5195
+ * Handles the ICECandidate event and
5196
+ * Initiates an ICE Trickle process with the SFU.
5197
+ */
5198
+ this.onIceCandidate = (e) => {
5199
+ const { candidate } = e;
5200
+ if (!candidate) {
5201
+ this.logger('debug', 'null ice candidate');
5202
+ return;
5203
+ }
5204
+ const iceCandidate = this.toJSON(candidate);
5205
+ this.sfuClient
5206
+ .iceTrickle({ peerType: this.peerType, iceCandidate })
5207
+ .catch((err) => this.logger('warn', `ICETrickle failed`, err));
5208
+ };
5209
+ /**
5210
+ * Converts the ICE candidate to a JSON string.
5211
+ */
5212
+ this.toJSON = (candidate) => {
5213
+ if (!candidate.usernameFragment) {
5214
+ // react-native-webrtc doesn't include usernameFragment in the candidate
5215
+ const segments = candidate.candidate.split(' ');
5216
+ const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
5217
+ const usernameFragment = segments[ufragIndex];
5218
+ return JSON.stringify({ ...candidate, usernameFragment });
5219
+ }
5220
+ return JSON.stringify(candidate.toJSON());
5221
+ };
5222
+ /**
5223
+ * Handles the ICE connection state change event.
5224
+ */
5225
+ this.onIceConnectionStateChange = () => {
5226
+ const state = this.pc.iceConnectionState;
5227
+ this.logger('debug', `ICE connection state changed`, state);
5228
+ if (this.state.callingState === CallingState.RECONNECTING)
5229
+ return;
5230
+ // do nothing when ICE is restarting
5231
+ if (this.isIceRestarting)
5232
+ return;
5233
+ if (state === 'failed' || state === 'disconnected') {
5234
+ this.logger('debug', `Attempting to restart ICE`);
5235
+ this.restartIce().catch((e) => {
5236
+ this.logger('error', `ICE restart failed`, e);
5237
+ this.onUnrecoverableError?.();
5238
+ });
5239
+ }
5240
+ };
5241
+ /**
5242
+ * Handles the ICE candidate error event.
5243
+ */
5244
+ this.onIceCandidateError = (e) => {
5245
+ const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
5246
+ `${e.errorCode}: ${e.errorText}`;
5247
+ const iceState = this.pc.iceConnectionState;
5248
+ const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
5249
+ this.logger(logLevel, `ICE Candidate error`, errorMessage);
5250
+ };
5251
+ /**
5252
+ * Handles the ICE gathering state change event.
5253
+ */
5254
+ this.onIceGatherChange = () => {
5255
+ this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
5256
+ };
5257
+ /**
5258
+ * Handles the signaling state change event.
5259
+ */
5260
+ this.onSignalingChange = () => {
5261
+ this.logger('debug', `Signaling state changed`, this.pc.signalingState);
5441
5262
  };
5263
+ this.peerType = peerType;
5264
+ this.sfuClient = sfuClient;
5265
+ this.state = state;
5266
+ this.dispatcher = dispatcher;
5267
+ this.onUnrecoverableError = onUnrecoverableError;
5268
+ this.logger = getLogger([
5269
+ peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
5270
+ logTag,
5271
+ ]);
5272
+ this.pc = new RTCPeerConnection(connectionConfig);
5273
+ this.pc.addEventListener('icecandidate', this.onIceCandidate);
5274
+ this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5275
+ this.pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5276
+ this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
5277
+ this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
5442
5278
  }
5443
- };
5444
- const getFmtp = (line) => {
5445
- // Example: a=fmtp:111 minptime=10; useinbandfec=1
5446
- const fmtpRegex = /^a=fmtp:(\d*) (.*)/;
5447
- const fmtpMatch = fmtpRegex.exec(line);
5448
- // The first captured group is the payload type number, the second captured group is any additional parameters.
5449
- if (fmtpMatch) {
5450
- return {
5451
- original: fmtpMatch[0],
5452
- payload: fmtpMatch[1],
5453
- config: fmtpMatch[2],
5279
+ /**
5280
+ * Detaches the event handlers from the `RTCPeerConnection`.
5281
+ */
5282
+ detachEventHandlers() {
5283
+ this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5284
+ this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5285
+ this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
5286
+ this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5287
+ this.pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
5288
+ this.unsubscribeIceTrickle?.();
5289
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
5290
+ }
5291
+ }
5292
+
5293
+ class TransceiverCache {
5294
+ constructor() {
5295
+ this.cache = [];
5296
+ this.layers = [];
5297
+ /**
5298
+ * An array maintaining the order how transceivers were added to the peer connection.
5299
+ * This is needed because some browsers (Firefox) don't reliably report
5300
+ * trackId and `mid` parameters.
5301
+ */
5302
+ this.transceiverOrder = [];
5303
+ /**
5304
+ * Adds a transceiver to the cache.
5305
+ */
5306
+ this.add = (publishOption, transceiver) => {
5307
+ this.cache.push({ publishOption, transceiver });
5308
+ this.transceiverOrder.push(transceiver);
5309
+ };
5310
+ /**
5311
+ * Gets the transceiver for the given publish option.
5312
+ */
5313
+ this.get = (publishOption) => {
5314
+ return this.findTransceiver(publishOption)?.transceiver;
5315
+ };
5316
+ /**
5317
+ * Gets the last transceiver for the given track type and publish option id.
5318
+ */
5319
+ this.getWith = (trackType, id) => {
5320
+ return this.findTransceiver({ trackType, id })?.transceiver;
5321
+ };
5322
+ /**
5323
+ * Checks if the cache has the given publish option.
5324
+ */
5325
+ this.has = (publishOption) => {
5326
+ return !!this.get(publishOption);
5327
+ };
5328
+ /**
5329
+ * Finds the first transceiver that satisfies the given predicate.
5330
+ */
5331
+ this.find = (predicate) => {
5332
+ return this.cache.find(predicate);
5333
+ };
5334
+ /**
5335
+ * Provides all the items in the cache.
5336
+ */
5337
+ this.items = () => {
5338
+ return this.cache;
5339
+ };
5340
+ /**
5341
+ * Init index of the transceiver in the cache.
5342
+ */
5343
+ this.indexOf = (transceiver) => {
5344
+ return this.transceiverOrder.indexOf(transceiver);
5454
5345
  };
5346
+ /**
5347
+ * Gets cached video layers for the given track.
5348
+ */
5349
+ this.getLayers = (publishOption) => {
5350
+ const entry = this.layers.find((item) => item.publishOption.id === publishOption.id &&
5351
+ item.publishOption.trackType === publishOption.trackType);
5352
+ return entry?.layers;
5353
+ };
5354
+ /**
5355
+ * Sets the video layers for the given track.
5356
+ */
5357
+ this.setLayers = (publishOption, layers = []) => {
5358
+ const entry = this.findLayer(publishOption);
5359
+ if (entry) {
5360
+ entry.layers = layers;
5361
+ }
5362
+ else {
5363
+ this.layers.push({ publishOption, layers });
5364
+ }
5365
+ };
5366
+ this.findTransceiver = (publishOption) => {
5367
+ return this.cache.find((item) => item.publishOption.id === publishOption.id &&
5368
+ item.publishOption.trackType === publishOption.trackType);
5369
+ };
5370
+ this.findLayer = (publishOption) => {
5371
+ return this.layers.find((item) => item.publishOption.id === publishOption.id &&
5372
+ item.publishOption.trackType === publishOption.trackType);
5373
+ };
5374
+ }
5375
+ }
5376
+
5377
+ const ensureExhausted = (x, message) => {
5378
+ getLogger(['helpers'])('warn', message, x);
5379
+ };
5380
+
5381
+ const trackTypeToParticipantStreamKey = (trackType) => {
5382
+ switch (trackType) {
5383
+ case TrackType.SCREEN_SHARE:
5384
+ return 'screenShareStream';
5385
+ case TrackType.SCREEN_SHARE_AUDIO:
5386
+ return 'screenShareAudioStream';
5387
+ case TrackType.VIDEO:
5388
+ return 'videoStream';
5389
+ case TrackType.AUDIO:
5390
+ return 'audioStream';
5391
+ case TrackType.UNSPECIFIED:
5392
+ throw new Error('Track type is unspecified');
5393
+ default:
5394
+ ensureExhausted(trackType, 'Unknown track type');
5455
5395
  }
5456
5396
  };
5397
+ const muteTypeToTrackType = (muteType) => {
5398
+ switch (muteType) {
5399
+ case 'audio':
5400
+ return TrackType.AUDIO;
5401
+ case 'video':
5402
+ return TrackType.VIDEO;
5403
+ case 'screenshare':
5404
+ return TrackType.SCREEN_SHARE;
5405
+ case 'screenshare_audio':
5406
+ return TrackType.SCREEN_SHARE_AUDIO;
5407
+ default:
5408
+ ensureExhausted(muteType, 'Unknown mute type');
5409
+ }
5410
+ };
5411
+ const toTrackType = (trackType) => {
5412
+ switch (trackType) {
5413
+ case 'TRACK_TYPE_AUDIO':
5414
+ return TrackType.AUDIO;
5415
+ case 'TRACK_TYPE_VIDEO':
5416
+ return TrackType.VIDEO;
5417
+ case 'TRACK_TYPE_SCREEN_SHARE':
5418
+ return TrackType.SCREEN_SHARE;
5419
+ case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
5420
+ return TrackType.SCREEN_SHARE_AUDIO;
5421
+ default:
5422
+ return undefined;
5423
+ }
5424
+ };
5425
+ const isAudioTrackType = (trackType) => trackType === TrackType.AUDIO || trackType === TrackType.SCREEN_SHARE_AUDIO;
5426
+
5427
+ const defaultBitratePerRid = {
5428
+ q: 300000,
5429
+ h: 750000,
5430
+ f: 1250000,
5431
+ };
5457
5432
  /**
5458
- * gets the media section for the specified media type.
5459
- * The media section contains the media type, port, codec, and payload type.
5460
- * Example: m=video 9 UDP/TLS/RTP/SAVPF 100 101 96 97 35 36 102 125 127
5433
+ * In SVC, we need to send only one video encoding (layer).
5434
+ * this layer will have the additional spatial and temporal layers
5435
+ * defined via the scalabilityMode property.
5436
+ *
5437
+ * @param layers the layers to process.
5461
5438
  */
5462
- const getMedia = (line, mediaType) => {
5463
- const regex = new RegExp(`(m=${mediaType} \\d+ [\\w/]+) ([\\d\\s]+)`);
5464
- const match = regex.exec(line);
5465
- if (match) {
5466
- return {
5467
- original: match[0],
5468
- mediaWithPorts: match[1],
5469
- codecOrder: match[2],
5470
- };
5471
- }
5439
+ const toSvcEncodings = (layers) => {
5440
+ if (!layers)
5441
+ return;
5442
+ // we take the highest quality layer, and we assign it to `q` encoder.
5443
+ const withRid = (rid) => (l) => l.rid === rid;
5444
+ const highestLayer = layers.find(withRid('f')) ||
5445
+ layers.find(withRid('h')) ||
5446
+ layers.find(withRid('q'));
5447
+ return [{ ...highestLayer, rid: 'q' }];
5472
5448
  };
5473
- const getMediaSection = (sdp, mediaType) => {
5474
- let media;
5475
- const rtpMap = [];
5476
- const fmtp = [];
5477
- let isTheRequiredMediaSection = false;
5478
- sdp.split(/(\r\n|\r|\n)/).forEach((line) => {
5479
- const isValidLine = /^([a-z])=(.*)/.test(line);
5480
- if (!isValidLine)
5481
- return;
5482
- /*
5483
- NOTE: according to https://www.rfc-editor.org/rfc/rfc8866.pdf
5484
- Each media description starts with an "m=" line and continues to the next media description or the end of the whole session description, whichever comes first
5485
- */
5486
- const type = line[0];
5487
- if (type === 'm') {
5488
- const _media = getMedia(line, mediaType);
5489
- isTheRequiredMediaSection = !!_media;
5490
- if (_media) {
5491
- media = _media;
5492
- }
5449
+ /**
5450
+ * Converts the rid to a video quality.
5451
+ */
5452
+ const ridToVideoQuality = (rid) => {
5453
+ return rid === 'q'
5454
+ ? VideoQuality.LOW_UNSPECIFIED
5455
+ : rid === 'h'
5456
+ ? VideoQuality.MID
5457
+ : VideoQuality.HIGH; // default to HIGH
5458
+ };
5459
+ /**
5460
+ * Converts the given video layers to SFU video layers.
5461
+ */
5462
+ const toVideoLayers = (layers = []) => {
5463
+ return layers.map((layer) => ({
5464
+ rid: layer.rid || '',
5465
+ bitrate: layer.maxBitrate || 0,
5466
+ fps: layer.maxFramerate || 0,
5467
+ quality: ridToVideoQuality(layer.rid || ''),
5468
+ videoDimension: { width: layer.width, height: layer.height },
5469
+ }));
5470
+ };
5471
+ /**
5472
+ * Converts the spatial and temporal layers to a scalability mode.
5473
+ */
5474
+ const toScalabilityMode = (spatialLayers, temporalLayers) => `L${spatialLayers}T${temporalLayers}${spatialLayers > 1 ? '_KEY' : ''}`;
5475
+ /**
5476
+ * Determines the most optimal video layers for the given track.
5477
+ *
5478
+ * @param videoTrack the video track to find optimal layers for.
5479
+ * @param publishOption the publish options for the track.
5480
+ */
5481
+ const computeVideoLayers = (videoTrack, publishOption) => {
5482
+ if (isAudioTrackType(publishOption.trackType))
5483
+ return;
5484
+ const optimalVideoLayers = [];
5485
+ const settings = videoTrack.getSettings();
5486
+ const { width = 0, height = 0 } = settings;
5487
+ const { bitrate, codec, fps, maxSpatialLayers = 3, maxTemporalLayers = 3, videoDimension = { width: 1280, height: 720 }, } = publishOption;
5488
+ const maxBitrate = getComputedMaxBitrate(videoDimension, width, height, bitrate);
5489
+ let downscaleFactor = 1;
5490
+ let bitrateFactor = 1;
5491
+ const svcCodec = isSvcCodec(codec?.name);
5492
+ for (const rid of ['f', 'h', 'q'].slice(0, maxSpatialLayers)) {
5493
+ const layer = {
5494
+ active: true,
5495
+ rid,
5496
+ width: Math.round(width / downscaleFactor),
5497
+ height: Math.round(height / downscaleFactor),
5498
+ maxBitrate: maxBitrate / bitrateFactor || defaultBitratePerRid[rid],
5499
+ maxFramerate: fps,
5500
+ };
5501
+ if (svcCodec) {
5502
+ // for SVC codecs, we need to set the scalability mode, and the
5503
+ // codec will handle the rest (layers, temporal layers, etc.)
5504
+ layer.scalabilityMode = toScalabilityMode(maxSpatialLayers, maxTemporalLayers);
5493
5505
  }
5494
- else if (isTheRequiredMediaSection && type === 'a') {
5495
- const rtpMapLine = getRtpMap(line);
5496
- const fmtpLine = getFmtp(line);
5497
- if (rtpMapLine) {
5498
- rtpMap.push(rtpMapLine);
5499
- }
5500
- else if (fmtpLine) {
5501
- fmtp.push(fmtpLine);
5502
- }
5506
+ else {
5507
+ // for non-SVC codecs, we need to downscale proportionally (simulcast)
5508
+ layer.scaleResolutionDownBy = downscaleFactor;
5503
5509
  }
5504
- });
5505
- if (media) {
5506
- return {
5507
- media,
5508
- rtpMap,
5509
- fmtp,
5510
- };
5510
+ downscaleFactor *= 2;
5511
+ bitrateFactor *= 2;
5512
+ // Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
5513
+ // when deciding which layer to disable when CPU or bandwidth is constrained.
5514
+ // Encodings should be ordered in increasing spatial resolution order.
5515
+ optimalVideoLayers.unshift(layer);
5511
5516
  }
5517
+ // for simplicity, we start with all layers enabled, then this function
5518
+ // will clear/reassign the layers that are not needed
5519
+ return withSimulcastConstraints(settings, optimalVideoLayers);
5512
5520
  };
5513
5521
  /**
5514
- * Gets the fmtp line corresponding to opus
5522
+ * Computes the maximum bitrate for a given resolution.
5523
+ * If the current resolution is lower than the target resolution,
5524
+ * we want to proportionally reduce the target bitrate.
5525
+ * If the current resolution is higher than the target resolution,
5526
+ * we want to use the target bitrate.
5527
+ *
5528
+ * @param targetResolution the target resolution.
5529
+ * @param currentWidth the current width of the track.
5530
+ * @param currentHeight the current height of the track.
5531
+ * @param bitrate the target bitrate.
5515
5532
  */
5516
- const getOpusFmtp = (sdp) => {
5517
- const section = getMediaSection(sdp, 'audio');
5518
- const rtpMap = section?.rtpMap.find((r) => r.codec.toLowerCase() === 'opus');
5519
- const codecId = rtpMap?.payload;
5520
- if (codecId) {
5521
- return section?.fmtp.find((f) => f.payload === codecId);
5533
+ const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, bitrate) => {
5534
+ // if the current resolution is lower than the target resolution,
5535
+ // we want to proportionally reduce the target bitrate
5536
+ const { width: targetWidth, height: targetHeight } = targetResolution;
5537
+ if (currentWidth < targetWidth || currentHeight < targetHeight) {
5538
+ const currentPixels = currentWidth * currentHeight;
5539
+ const targetPixels = targetWidth * targetHeight;
5540
+ const reductionFactor = currentPixels / targetPixels;
5541
+ return Math.round(bitrate * reductionFactor);
5522
5542
  }
5543
+ return bitrate;
5523
5544
  };
5524
5545
  /**
5525
- * Returns an SDP with DTX enabled or disabled.
5526
- */
5527
- const toggleDtx = (sdp, enable) => {
5528
- const opusFmtp = getOpusFmtp(sdp);
5529
- if (!opusFmtp)
5530
- return sdp;
5531
- const matchDtx = /usedtx=(\d)/.exec(opusFmtp.config);
5532
- const requiredDtxConfig = `usedtx=${enable ? '1' : '0'}`;
5533
- const newFmtp = matchDtx
5534
- ? opusFmtp.original.replace(/usedtx=(\d)/, requiredDtxConfig)
5535
- : `${opusFmtp.original};${requiredDtxConfig}`;
5536
- return sdp.replace(opusFmtp.original, newFmtp);
5537
- };
5538
- /**
5539
- * Returns and SDP with all the codecs except the given codec removed.
5540
- */
5541
- const preserveCodec = (sdp, mid, codec) => {
5542
- const [kind, codecName] = codec.mimeType.toLowerCase().split('/');
5543
- const toSet = (fmtpLine) => new Set(fmtpLine.split(';').map((f) => f.trim().toLowerCase()));
5544
- const equal = (a, b) => {
5545
- if (a.size !== b.size)
5546
- return false;
5547
- for (const item of a)
5548
- if (!b.has(item))
5549
- return false;
5550
- return true;
5551
- };
5552
- const codecFmtp = toSet(codec.sdpFmtpLine || '');
5553
- const parsedSdp = SDP.parse(sdp);
5554
- for (const media of parsedSdp.media) {
5555
- if (media.type !== kind || String(media.mid) !== mid)
5556
- continue;
5557
- // find the payload id of the desired codec
5558
- const payloads = new Set();
5559
- for (const rtp of media.rtp) {
5560
- if (rtp.codec.toLowerCase() !== codecName)
5561
- continue;
5562
- const match =
5563
- // vp8 doesn't have any fmtp, we preserve it without any additional checks
5564
- codecName === 'vp8'
5565
- ? true
5566
- : media.fmtp.some((f) => f.payload === rtp.payload && equal(toSet(f.config), codecFmtp));
5567
- if (match) {
5568
- payloads.add(rtp.payload);
5569
- }
5570
- }
5571
- // find the corresponding rtx codec by matching apt=<preserved-codec-payload>
5572
- for (const fmtp of media.fmtp) {
5573
- const match = fmtp.config.match(/(apt)=(\d+)/);
5574
- if (!match)
5575
- continue;
5576
- const [, , preservedCodecPayload] = match;
5577
- if (payloads.has(Number(preservedCodecPayload))) {
5578
- payloads.add(fmtp.payload);
5579
- }
5580
- }
5581
- media.rtp = media.rtp.filter((r) => payloads.has(r.payload));
5582
- media.fmtp = media.fmtp.filter((f) => payloads.has(f.payload));
5583
- media.rtcpFb = media.rtcpFb?.filter((f) => payloads.has(f.payload));
5584
- media.payloads = Array.from(payloads).join(' ');
5585
- }
5586
- return SDP.write(parsedSdp);
5587
- };
5588
- /**
5589
- * Enables high-quality audio through SDP munging for the given trackMid.
5546
+ * Browsers have different simulcast constraints for different video resolutions.
5590
5547
  *
5591
- * @param sdp the SDP to munge.
5592
- * @param trackMid the trackMid.
5593
- * @param maxBitrate the max bitrate to set.
5594
- */
5595
- const enableHighQualityAudio = (sdp, trackMid, maxBitrate = 510000) => {
5596
- maxBitrate = Math.max(Math.min(maxBitrate, 510000), 96000);
5597
- const parsedSdp = SDP.parse(sdp);
5598
- const audioMedia = parsedSdp.media.find((m) => m.type === 'audio' && String(m.mid) === trackMid);
5599
- if (!audioMedia)
5600
- return sdp;
5601
- const opusRtp = audioMedia.rtp.find((r) => r.codec === 'opus');
5602
- if (!opusRtp)
5603
- return sdp;
5604
- const opusFmtp = audioMedia.fmtp.find((f) => f.payload === opusRtp.payload);
5605
- if (!opusFmtp)
5606
- return sdp;
5607
- // enable stereo, if not already enabled
5608
- if (opusFmtp.config.match(/stereo=(\d)/)) {
5609
- opusFmtp.config = opusFmtp.config.replace(/stereo=(\d)/, 'stereo=1');
5610
- }
5611
- else {
5612
- opusFmtp.config = `${opusFmtp.config};stereo=1`;
5548
+ * This function modifies the provided list of video layers according to the
5549
+ * current implementation of simulcast constraints in the Chromium based browsers.
5550
+ *
5551
+ * https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
5552
+ */
5553
+ const withSimulcastConstraints = (settings, optimalVideoLayers) => {
5554
+ let layers;
5555
+ const size = Math.max(settings.width || 0, settings.height || 0);
5556
+ if (size <= 320) {
5557
+ // provide only one layer 320x240 (q), the one with the highest quality
5558
+ layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
5613
5559
  }
5614
- // set maxaveragebitrate, to the given value
5615
- if (opusFmtp.config.match(/maxaveragebitrate=(\d*)/)) {
5616
- opusFmtp.config = opusFmtp.config.replace(/maxaveragebitrate=(\d*)/, `maxaveragebitrate=${maxBitrate}`);
5560
+ else if (size <= 640) {
5561
+ // provide two layers, 160x120 (q) and 640x480 (h)
5562
+ layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
5617
5563
  }
5618
5564
  else {
5619
- opusFmtp.config = `${opusFmtp.config};maxaveragebitrate=${maxBitrate}`;
5565
+ // provide three layers for sizes > 640x480
5566
+ layers = optimalVideoLayers;
5620
5567
  }
5621
- return SDP.write(parsedSdp);
5568
+ const ridMapping = ['q', 'h', 'f'];
5569
+ return layers.map((layer, index) => ({
5570
+ ...layer,
5571
+ rid: ridMapping[index], // reassign rid
5572
+ }));
5622
5573
  };
5574
+
5623
5575
  /**
5624
5576
  * Extracts the mid from the transceiver or the SDP.
5625
5577
  *
@@ -5631,9 +5583,9 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5631
5583
  if (transceiver.mid)
5632
5584
  return transceiver.mid;
5633
5585
  if (!sdp)
5634
- return '';
5586
+ return String(transceiverInitIndex);
5635
5587
  const track = transceiver.sender.track;
5636
- const parsedSdp = SDP.parse(sdp);
5588
+ const parsedSdp = parse(sdp);
5637
5589
  const media = parsedSdp.media.find((m) => {
5638
5590
  return (m.type === track.kind &&
5639
5591
  // if `msid` is not present, we assume that the track is the first one
@@ -5641,7 +5593,7 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5641
5593
  });
5642
5594
  if (typeof media?.mid !== 'undefined')
5643
5595
  return String(media.mid);
5644
- if (transceiverInitIndex === -1)
5596
+ if (transceiverInitIndex < 0)
5645
5597
  return '';
5646
5598
  return String(transceiverInitIndex);
5647
5599
  };
@@ -5651,164 +5603,87 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
5651
5603
  *
5652
5604
  * @internal
5653
5605
  */
5654
- class Publisher {
5606
+ class Publisher extends BasePeerConnection {
5655
5607
  /**
5656
5608
  * Constructs a new `Publisher` instance.
5657
5609
  */
5658
- constructor({ connectionConfig, sfuClient, dispatcher, state, isDtxEnabled, isRedEnabled, onUnrecoverableError, logTag, }) {
5659
- this.transceiverCache = new Map();
5660
- this.trackLayersCache = new Map();
5661
- this.publishOptsForTrack = new Map();
5662
- /**
5663
- * An array maintaining the order how transceivers were added to the peer connection.
5664
- * This is needed because some browsers (Firefox) don't reliably report
5665
- * trackId and `mid` parameters.
5666
- *
5667
- * @internal
5668
- */
5669
- this.transceiverInitOrder = [];
5670
- this.isIceRestarting = false;
5671
- this.createPeerConnection = (connectionConfig) => {
5672
- const pc = new RTCPeerConnection(connectionConfig);
5673
- pc.addEventListener('icecandidate', this.onIceCandidate);
5674
- pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5675
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
5676
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5677
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5678
- pc.addEventListener('signalingstatechange', this.onSignalingStateChange);
5679
- return pc;
5680
- };
5681
- /**
5682
- * Closes the publisher PeerConnection and cleans up the resources.
5683
- */
5684
- this.close = ({ stopTracks }) => {
5685
- if (stopTracks) {
5686
- this.stopPublishing();
5687
- this.transceiverCache.clear();
5688
- this.trackLayersCache.clear();
5689
- }
5690
- this.detachEventHandlers();
5691
- this.pc.close();
5692
- };
5693
- /**
5694
- * Detaches the event handlers from the `RTCPeerConnection`.
5695
- * This is useful when we want to replace the `RTCPeerConnection`
5696
- * instance with a new one (in case of migration).
5697
- */
5698
- this.detachEventHandlers = () => {
5699
- this.unsubscribeOnIceRestart();
5700
- this.unsubscribeChangePublishQuality();
5701
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
5702
- this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5703
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
5704
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
5705
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5706
- this.pc.removeEventListener('signalingstatechange', this.onSignalingStateChange);
5707
- };
5610
+ constructor({ publishOptions, ...baseOptions }) {
5611
+ super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
5612
+ this.transceiverCache = new TransceiverCache();
5708
5613
  /**
5709
5614
  * Starts publishing the given track of the given media stream.
5710
5615
  *
5711
5616
  * Consecutive calls to this method will replace the stream.
5712
5617
  * The previous stream will be stopped.
5713
5618
  *
5714
- * @param mediaStream the media stream to publish.
5715
5619
  * @param track the track to publish.
5716
5620
  * @param trackType the track type to publish.
5717
- * @param opts the optional publish options to use.
5718
5621
  */
5719
- this.publishStream = async (mediaStream, track, trackType, opts = {}) => {
5720
- if (track.readyState === 'ended') {
5721
- throw new Error(`Can't publish a track that has ended already.`);
5722
- }
5723
- // enable the track if it is disabled
5724
- if (!track.enabled)
5725
- track.enabled = true;
5726
- const transceiver = this.transceiverCache.get(trackType);
5727
- if (!transceiver || !transceiver.sender.track) {
5728
- // listen for 'ended' event on the track as it might be ended abruptly
5729
- // by an external factors such as permission revokes, a disconnected device, etc.
5730
- // keep in mind that `track.stop()` doesn't trigger this event.
5731
- const handleTrackEnded = () => {
5732
- this.logger('info', `Track ${TrackType[trackType]} has ended abruptly`);
5733
- track.removeEventListener('ended', handleTrackEnded);
5734
- this.notifyTrackMuteStateChanged(mediaStream, trackType, true).catch((err) => this.logger('warn', `Couldn't notify track mute state`, err));
5735
- };
5736
- track.addEventListener('ended', handleTrackEnded);
5737
- this.addTransceiver(trackType, track, opts, mediaStream);
5622
+ this.publish = async (track, trackType) => {
5623
+ if (!this.publishOptions.some((o) => o.trackType === trackType)) {
5624
+ throw new Error(`No publish options found for ${TrackType[trackType]}`);
5738
5625
  }
5739
- else {
5740
- await this.updateTransceiver(transceiver, track);
5626
+ for (const publishOption of this.publishOptions) {
5627
+ if (publishOption.trackType !== trackType)
5628
+ continue;
5629
+ // create a clone of the track as otherwise the same trackId will
5630
+ // appear in the SDP in multiple transceivers
5631
+ const trackToPublish = track.clone();
5632
+ const transceiver = this.transceiverCache.get(publishOption);
5633
+ if (!transceiver) {
5634
+ this.addTransceiver(trackToPublish, publishOption);
5635
+ }
5636
+ else {
5637
+ await transceiver.sender.replaceTrack(trackToPublish);
5638
+ }
5741
5639
  }
5742
- await this.notifyTrackMuteStateChanged(mediaStream, trackType, false);
5743
5640
  };
5744
5641
  /**
5745
- * Adds a new transceiver to the peer connection.
5746
- * This needs to be called when a new track kind is added to the peer connection.
5747
- * In other cases, use `updateTransceiver` method.
5642
+ * Adds a new transceiver carrying the given track to the peer connection.
5748
5643
  */
5749
- this.addTransceiver = (trackType, track, opts, mediaStream) => {
5750
- const { forceCodec, preferredCodec } = opts;
5751
- const codecInUse = forceCodec || getOptimalVideoCodec(preferredCodec);
5752
- const videoEncodings = this.computeLayers(trackType, track, opts);
5644
+ this.addTransceiver = (track, publishOption) => {
5645
+ const videoEncodings = computeVideoLayers(track, publishOption);
5646
+ const sendEncodings = isSvcCodec(publishOption.codec?.name)
5647
+ ? toSvcEncodings(videoEncodings)
5648
+ : videoEncodings;
5753
5649
  const transceiver = this.pc.addTransceiver(track, {
5754
5650
  direction: 'sendonly',
5755
- streams: trackType === TrackType.VIDEO || trackType === TrackType.SCREEN_SHARE
5756
- ? [mediaStream]
5757
- : undefined,
5758
- sendEncodings: isSvcCodec(codecInUse)
5759
- ? toSvcEncodings(videoEncodings)
5760
- : videoEncodings,
5651
+ sendEncodings,
5761
5652
  });
5653
+ const trackType = publishOption.trackType;
5762
5654
  this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
5763
- this.transceiverInitOrder.push(trackType);
5764
- this.transceiverCache.set(trackType, transceiver);
5765
- this.publishOptsForTrack.set(trackType, opts);
5766
- // handle codec preferences
5767
- if (!('setCodecPreferences' in transceiver))
5768
- return;
5769
- const codecPreferences = this.getCodecPreferences(trackType, trackType === TrackType.VIDEO ? codecInUse : undefined, 'receiver');
5770
- if (!codecPreferences)
5771
- return;
5772
- try {
5773
- this.logger('info', `Setting ${TrackType[trackType]} codec preferences`, codecPreferences);
5774
- transceiver.setCodecPreferences(codecPreferences);
5775
- }
5776
- catch (err) {
5777
- this.logger('warn', `Couldn't set codec preferences`, err);
5778
- }
5779
- };
5780
- /**
5781
- * Updates the given transceiver with the new track.
5782
- * Stops the previous track and replaces it with the new one.
5783
- */
5784
- this.updateTransceiver = async (transceiver, track) => {
5785
- const previousTrack = transceiver.sender.track;
5786
- // don't stop the track if we are re-publishing the same track
5787
- if (previousTrack && previousTrack !== track) {
5788
- previousTrack.stop();
5789
- }
5790
- await transceiver.sender.replaceTrack(track);
5655
+ this.transceiverCache.add(publishOption, transceiver);
5791
5656
  };
5792
5657
  /**
5793
- * Stops publishing the given track type to the SFU, if it is currently being published.
5794
- * Underlying track will be stopped and removed from the publisher.
5795
- * @param trackType the track type to unpublish.
5796
- * @param stopTrack specifies whether track should be stopped or just disabled
5658
+ * Synchronizes the current Publisher state with the provided publish options.
5797
5659
  */
5798
- this.unpublishStream = async (trackType, stopTrack) => {
5799
- const transceiver = this.transceiverCache.get(trackType);
5800
- if (transceiver &&
5801
- transceiver.sender.track &&
5802
- (stopTrack
5803
- ? transceiver.sender.track.readyState === 'live'
5804
- : transceiver.sender.track.enabled)) {
5805
- stopTrack
5806
- ? transceiver.sender.track.stop()
5807
- : (transceiver.sender.track.enabled = false);
5808
- // We don't need to notify SFU if unpublishing in response to remote soft mute
5809
- if (this.state.localParticipant?.publishedTracks.includes(trackType)) {
5810
- await this.notifyTrackMuteStateChanged(undefined, trackType, true);
5811
- }
5660
+ this.syncPublishOptions = async () => {
5661
+ // enable publishing with new options -> [av1, vp9]
5662
+ for (const publishOption of this.publishOptions) {
5663
+ const { trackType } = publishOption;
5664
+ if (!this.isPublishing(trackType))
5665
+ continue;
5666
+ if (this.transceiverCache.has(publishOption))
5667
+ continue;
5668
+ const item = this.transceiverCache.find((i) => !!i.transceiver.sender.track &&
5669
+ i.publishOption.trackType === trackType);
5670
+ if (!item || !item.transceiver)
5671
+ continue;
5672
+ // take the track from the existing transceiver for the same track type,
5673
+ // clone it and publish it with the new publish options
5674
+ const track = item.transceiver.sender.track.clone();
5675
+ this.addTransceiver(track, publishOption);
5676
+ }
5677
+ // stop publishing with options not required anymore -> [vp9]
5678
+ for (const item of this.transceiverCache.items()) {
5679
+ const { publishOption, transceiver } = item;
5680
+ const hasPublishOption = this.publishOptions.some((option) => option.id === publishOption.id &&
5681
+ option.trackType === publishOption.trackType);
5682
+ if (hasPublishOption)
5683
+ continue;
5684
+ // it is safe to stop the track here, it is a clone
5685
+ transceiver.sender.track?.stop();
5686
+ await transceiver.sender.replaceTrack(null);
5812
5687
  }
5813
5688
  };
5814
5689
  /**
@@ -5817,57 +5692,52 @@ class Publisher {
5817
5692
  * @param trackType the track type to check.
5818
5693
  */
5819
5694
  this.isPublishing = (trackType) => {
5820
- const transceiver = this.transceiverCache.get(trackType);
5821
- if (!transceiver || !transceiver.sender)
5822
- return false;
5823
- const track = transceiver.sender.track;
5824
- return !!track && track.readyState === 'live' && track.enabled;
5825
- };
5826
- this.notifyTrackMuteStateChanged = async (mediaStream, trackType, isMuted) => {
5827
- await this.sfuClient.updateMuteState(trackType, isMuted);
5828
- const audioOrVideoOrScreenShareStream = trackTypeToParticipantStreamKey(trackType);
5829
- if (!audioOrVideoOrScreenShareStream)
5830
- return;
5831
- if (isMuted) {
5832
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => ({
5833
- publishedTracks: p.publishedTracks.filter((t) => t !== trackType),
5834
- [audioOrVideoOrScreenShareStream]: undefined,
5835
- }));
5836
- }
5837
- else {
5838
- this.state.updateParticipant(this.sfuClient.sessionId, (p) => {
5839
- return {
5840
- publishedTracks: p.publishedTracks.includes(trackType)
5841
- ? p.publishedTracks
5842
- : [...p.publishedTracks, trackType],
5843
- [audioOrVideoOrScreenShareStream]: mediaStream,
5844
- };
5845
- });
5695
+ for (const item of this.transceiverCache.items()) {
5696
+ if (item.publishOption.trackType !== trackType)
5697
+ continue;
5698
+ const track = item.transceiver.sender.track;
5699
+ if (!track)
5700
+ continue;
5701
+ if (track.readyState === 'live' && track.enabled)
5702
+ return true;
5846
5703
  }
5704
+ return false;
5847
5705
  };
5848
5706
  /**
5849
- * Stops publishing all tracks and stop all tracks.
5707
+ * Maps the given track ID to the corresponding track type.
5850
5708
  */
5851
- this.stopPublishing = () => {
5852
- this.logger('debug', 'Stopping publishing all tracks');
5853
- this.pc.getSenders().forEach((s) => {
5854
- s.track?.stop();
5855
- if (this.pc.signalingState !== 'closed') {
5856
- this.pc.removeTrack(s);
5709
+ this.getTrackType = (trackId) => {
5710
+ for (const transceiverId of this.transceiverCache.items()) {
5711
+ const { publishOption, transceiver } = transceiverId;
5712
+ if (transceiver.sender.track?.id === trackId) {
5713
+ return publishOption.trackType;
5857
5714
  }
5858
- });
5715
+ }
5716
+ return undefined;
5859
5717
  };
5860
- this.changePublishQuality = async (enabledLayers) => {
5861
- this.logger('info', 'Update publish quality, requested layers by SFU:', enabledLayers);
5862
- const videoSender = this.transceiverCache.get(TrackType.VIDEO)?.sender;
5863
- if (!videoSender) {
5864
- this.logger('warn', 'Update publish quality, no video sender found.');
5865
- return;
5718
+ /**
5719
+ * Stops the cloned track that is being published to the SFU.
5720
+ */
5721
+ this.stopTracks = (...trackTypes) => {
5722
+ for (const item of this.transceiverCache.items()) {
5723
+ const { publishOption, transceiver } = item;
5724
+ if (!trackTypes.includes(publishOption.trackType))
5725
+ continue;
5726
+ transceiver.sender.track?.stop();
5727
+ }
5728
+ };
5729
+ this.changePublishQuality = async (videoSender) => {
5730
+ const { trackType, layers, publishOptionId } = videoSender;
5731
+ const enabledLayers = layers.filter((l) => l.active);
5732
+ const tag = 'Update publish quality:';
5733
+ this.logger('info', `${tag} requested layers by SFU:`, enabledLayers);
5734
+ const sender = this.transceiverCache.getWith(trackType, publishOptionId)?.sender;
5735
+ if (!sender) {
5736
+ return this.logger('warn', `${tag} no video sender found.`);
5866
5737
  }
5867
- const params = videoSender.getParameters();
5738
+ const params = sender.getParameters();
5868
5739
  if (params.encodings.length === 0) {
5869
- this.logger('warn', 'Update publish quality, No suitable video encoding quality found');
5870
- return;
5740
+ return this.logger('warn', `${tag} there are no encodings set.`);
5871
5741
  }
5872
5742
  const [codecInUse] = params.codecs;
5873
5743
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse.mimeType);
@@ -5909,54 +5779,12 @@ class Publisher {
5909
5779
  changed = true;
5910
5780
  }
5911
5781
  }
5912
- const activeLayers = params.encodings.filter((e) => e.active);
5782
+ const activeEncoders = params.encodings.filter((e) => e.active);
5913
5783
  if (!changed) {
5914
- this.logger('info', `Update publish quality, no change:`, activeLayers);
5915
- return;
5916
- }
5917
- await videoSender.setParameters(params);
5918
- this.logger('info', `Update publish quality, enabled rids:`, activeLayers);
5919
- };
5920
- /**
5921
- * Returns the result of the `RTCPeerConnection.getStats()` method
5922
- * @param selector
5923
- * @returns
5924
- */
5925
- this.getStats = (selector) => {
5926
- return this.pc.getStats(selector);
5927
- };
5928
- this.getCodecPreferences = (trackType, preferredCodec, codecPreferencesSource) => {
5929
- if (trackType === TrackType.VIDEO) {
5930
- return getPreferredCodecs('video', preferredCodec || 'vp8', undefined, codecPreferencesSource);
5931
- }
5932
- if (trackType === TrackType.AUDIO) {
5933
- const defaultAudioCodec = this.isRedEnabled ? 'red' : 'opus';
5934
- const codecToRemove = !this.isRedEnabled ? 'red' : undefined;
5935
- return getPreferredCodecs('audio', preferredCodec ?? defaultAudioCodec, codecToRemove, codecPreferencesSource);
5936
- }
5937
- };
5938
- this.onIceCandidate = (e) => {
5939
- const { candidate } = e;
5940
- if (!candidate) {
5941
- this.logger('debug', 'null ice candidate');
5942
- return;
5784
+ return this.logger('info', `${tag} no change:`, activeEncoders);
5943
5785
  }
5944
- this.sfuClient
5945
- .iceTrickle({
5946
- iceCandidate: getIceCandidate(candidate),
5947
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
5948
- })
5949
- .catch((err) => {
5950
- this.logger('warn', `ICETrickle failed`, err);
5951
- });
5952
- };
5953
- /**
5954
- * Sets the SFU client to use.
5955
- *
5956
- * @param sfuClient the SFU client to use.
5957
- */
5958
- this.setSfuClient = (sfuClient) => {
5959
- this.sfuClient = sfuClient;
5786
+ await sender.setParameters(params);
5787
+ this.logger('info', `${tag} enabled rids:`, activeEncoders);
5960
5788
  };
5961
5789
  /**
5962
5790
  * Restarts the ICE connection and renegotiates with the SFU.
@@ -5971,7 +5799,7 @@ class Publisher {
5971
5799
  await this.negotiate({ iceRestart: true });
5972
5800
  };
5973
5801
  this.onNegotiationNeeded = () => {
5974
- this.negotiate().catch((err) => {
5802
+ withoutConcurrency('publisher.negotiate', () => this.negotiate()).catch((err) => {
5975
5803
  this.logger('error', `Negotiation failed.`, err);
5976
5804
  this.onUnrecoverableError?.();
5977
5805
  });
@@ -5983,18 +5811,6 @@ class Publisher {
5983
5811
  */
5984
5812
  this.negotiate = async (options) => {
5985
5813
  const offer = await this.pc.createOffer(options);
5986
- if (offer.sdp) {
5987
- offer.sdp = toggleDtx(offer.sdp, this.isDtxEnabled);
5988
- if (this.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
5989
- offer.sdp = this.enableHighQualityAudio(offer.sdp);
5990
- }
5991
- if (this.isPublishing(TrackType.VIDEO)) {
5992
- // Hotfix for platforms that don't respect the ordered codec list
5993
- // (Firefox, Android, Linux, etc...).
5994
- // We remove all the codecs from the SDP except the one we want to use.
5995
- offer.sdp = this.removeUnpreferredCodecs(offer.sdp, TrackType.VIDEO);
5996
- }
5997
- }
5998
5814
  const trackInfos = this.getAnnouncedTracks(offer.sdp);
5999
5815
  if (trackInfos.length === 0) {
6000
5816
  throw new Error(`Can't negotiate without announcing any tracks`);
@@ -6013,238 +5829,121 @@ class Publisher {
6013
5829
  finally {
6014
5830
  this.isIceRestarting = false;
6015
5831
  }
6016
- this.sfuClient.iceTrickleBuffer.publisherCandidates.subscribe(async (candidate) => {
6017
- try {
6018
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6019
- await this.pc.addIceCandidate(iceCandidate);
6020
- }
6021
- catch (e) {
6022
- this.logger('warn', `ICE candidate error`, e, candidate);
6023
- }
6024
- });
6025
- };
6026
- this.enableHighQualityAudio = (sdp) => {
6027
- const transceiver = this.transceiverCache.get(TrackType.SCREEN_SHARE_AUDIO);
6028
- if (!transceiver)
6029
- return sdp;
6030
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(TrackType.SCREEN_SHARE_AUDIO);
6031
- const mid = extractMid(transceiver, transceiverInitIndex, sdp);
6032
- return enableHighQualityAudio(sdp, mid);
5832
+ this.addTrickledIceCandidates();
6033
5833
  };
6034
5834
  /**
6035
5835
  * Returns a list of tracks that are currently being published.
6036
- *
6037
- * @internal
6038
- * @param sdp an optional SDP to extract the `mid` from.
6039
5836
  */
6040
- this.getAnnouncedTracks = (sdp) => {
6041
- sdp = sdp || this.pc.localDescription?.sdp;
6042
- return this.pc
6043
- .getTransceivers()
6044
- .filter((t) => t.direction === 'sendonly' && t.sender.track)
6045
- .map((transceiver) => {
6046
- let trackType;
6047
- this.transceiverCache.forEach((value, key) => {
6048
- if (value === transceiver)
6049
- trackType = key;
6050
- });
5837
+ this.getPublishedTracks = () => {
5838
+ const tracks = [];
5839
+ for (const { transceiver } of this.transceiverCache.items()) {
6051
5840
  const track = transceiver.sender.track;
6052
- let optimalLayers;
6053
- const isTrackLive = track.readyState === 'live';
6054
- if (isTrackLive) {
6055
- optimalLayers = this.computeLayers(trackType, track) || [];
6056
- this.trackLayersCache.set(trackType, optimalLayers);
6057
- }
6058
- else {
6059
- // we report the last known optimal layers for ended tracks
6060
- optimalLayers = this.trackLayersCache.get(trackType) || [];
6061
- this.logger('debug', `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`, optimalLayers);
6062
- }
6063
- const layers = optimalLayers.map((optimalLayer) => ({
6064
- rid: optimalLayer.rid || '',
6065
- bitrate: optimalLayer.maxBitrate || 0,
6066
- fps: optimalLayer.maxFramerate || 0,
6067
- quality: ridToVideoQuality(optimalLayer.rid || ''),
6068
- videoDimension: {
6069
- width: optimalLayer.width,
6070
- height: optimalLayer.height,
6071
- },
6072
- }));
6073
- const isAudioTrack = [
6074
- TrackType.AUDIO,
6075
- TrackType.SCREEN_SHARE_AUDIO,
6076
- ].includes(trackType);
6077
- const trackSettings = track.getSettings();
6078
- const isStereo = isAudioTrack && trackSettings.channelCount === 2;
6079
- const transceiverInitIndex = this.transceiverInitOrder.indexOf(trackType);
6080
- return {
6081
- trackId: track.id,
6082
- layers: layers,
6083
- trackType,
6084
- mid: extractMid(transceiver, transceiverInitIndex, sdp),
6085
- stereo: isStereo,
6086
- dtx: isAudioTrack && this.isDtxEnabled,
6087
- red: isAudioTrack && this.isRedEnabled,
6088
- muted: !isTrackLive,
6089
- };
6090
- });
6091
- };
6092
- this.computeLayers = (trackType, track, opts) => {
6093
- const { settings } = this.state;
6094
- const targetResolution = settings?.video
6095
- .target_resolution;
6096
- const screenShareBitrate = settings?.screensharing.target_resolution?.bitrate;
6097
- const publishOpts = opts || this.publishOptsForTrack.get(trackType);
6098
- const codecInUse = opts?.forceCodec || getOptimalVideoCodec(opts?.preferredCodec);
6099
- return trackType === TrackType.VIDEO
6100
- ? findOptimalVideoLayers(track, targetResolution, codecInUse, publishOpts)
6101
- : trackType === TrackType.SCREEN_SHARE
6102
- ? findOptimalScreenSharingLayers(track, publishOpts, screenShareBitrate)
6103
- : undefined;
6104
- };
6105
- this.onIceCandidateError = (e) => {
6106
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6107
- `${e.errorCode}: ${e.errorText}`;
6108
- const iceState = this.pc.iceConnectionState;
6109
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6110
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6111
- };
6112
- this.onIceConnectionStateChange = () => {
6113
- const state = this.pc.iceConnectionState;
6114
- this.logger('debug', `ICE Connection state changed to`, state);
6115
- if (this.state.callingState === CallingState.RECONNECTING)
6116
- return;
6117
- if (state === 'failed' || state === 'disconnected') {
6118
- this.logger('debug', `Attempting to restart ICE`);
6119
- this.restartIce().catch((e) => {
6120
- this.logger('error', `ICE restart error`, e);
6121
- this.onUnrecoverableError?.();
6122
- });
5841
+ if (track && track.readyState === 'live')
5842
+ tracks.push(track);
6123
5843
  }
6124
- };
6125
- this.onIceGatheringStateChange = () => {
6126
- this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
6127
- };
6128
- this.onSignalingStateChange = () => {
6129
- this.logger('debug', `Signaling state changed`, this.pc.signalingState);
6130
- };
6131
- this.logger = getLogger(['Publisher', logTag]);
6132
- this.pc = this.createPeerConnection(connectionConfig);
6133
- this.sfuClient = sfuClient;
6134
- this.state = state;
6135
- this.isDtxEnabled = isDtxEnabled;
6136
- this.isRedEnabled = isRedEnabled;
6137
- this.onUnrecoverableError = onUnrecoverableError;
6138
- this.unsubscribeOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6139
- if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
6140
- return;
6141
- this.restartIce().catch((err) => {
6142
- this.logger('warn', `ICERestart failed`, err);
6143
- this.onUnrecoverableError?.();
6144
- });
6145
- });
6146
- this.unsubscribeChangePublishQuality = dispatcher.on('changePublishQuality', ({ videoSenders }) => {
6147
- withoutConcurrency('publisher.changePublishQuality', async () => {
6148
- for (const videoSender of videoSenders) {
6149
- const { layers } = videoSender;
6150
- const enabledLayers = layers.filter((l) => l.active);
6151
- await this.changePublishQuality(enabledLayers);
6152
- }
6153
- }).catch((err) => {
6154
- this.logger('warn', 'Failed to change publish quality', err);
6155
- });
6156
- });
6157
- }
6158
- removeUnpreferredCodecs(sdp, trackType) {
6159
- const opts = this.publishOptsForTrack.get(trackType);
6160
- const forceSingleCodec = !!opts?.forceSingleCodec || isReactNative() || isFirefox();
6161
- if (!opts || !forceSingleCodec)
6162
- return sdp;
6163
- const codec = opts.forceCodec || getOptimalVideoCodec(opts.preferredCodec);
6164
- const orderedCodecs = this.getCodecPreferences(trackType, codec, 'sender');
6165
- if (!orderedCodecs || orderedCodecs.length === 0)
6166
- return sdp;
6167
- const transceiver = this.transceiverCache.get(trackType);
6168
- if (!transceiver)
6169
- return sdp;
6170
- const index = this.transceiverInitOrder.indexOf(trackType);
6171
- const mid = extractMid(transceiver, index, sdp);
6172
- const [codecToPreserve] = orderedCodecs;
6173
- return preserveCodec(sdp, mid, codecToPreserve);
6174
- }
6175
- }
6176
-
6177
- /**
6178
- * A wrapper around the `RTCPeerConnection` that handles the incoming
6179
- * media streams from the SFU.
6180
- *
6181
- * @internal
6182
- */
6183
- class Subscriber {
6184
- /**
6185
- * Constructs a new `Subscriber` instance.
6186
- *
6187
- * @param sfuClient the SFU client to use.
6188
- * @param dispatcher the dispatcher to use.
6189
- * @param state the state of the call.
6190
- * @param connectionConfig the connection configuration to use.
6191
- * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
6192
- * @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
6193
- * @param logTag a tag to use for logging.
6194
- */
6195
- constructor({ sfuClient, dispatcher, state, connectionConfig, onUnrecoverableError, logTag, }) {
6196
- this.isIceRestarting = false;
6197
- /**
6198
- * Creates a new `RTCPeerConnection` instance with the given configuration.
6199
- *
6200
- * @param connectionConfig the connection configuration to use.
6201
- */
6202
- this.createPeerConnection = (connectionConfig) => {
6203
- const pc = new RTCPeerConnection(connectionConfig);
6204
- pc.addEventListener('icecandidate', this.onIceCandidate);
6205
- pc.addEventListener('track', this.handleOnTrack);
6206
- pc.addEventListener('icecandidateerror', this.onIceCandidateError);
6207
- pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6208
- pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
6209
- return pc;
6210
- };
6211
- /**
6212
- * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
6213
- */
6214
- this.close = () => {
6215
- this.detachEventHandlers();
6216
- this.pc.close();
5844
+ return tracks;
6217
5845
  };
6218
5846
  /**
6219
- * Detaches the event handlers from the `RTCPeerConnection`.
6220
- * This is useful when we want to replace the `RTCPeerConnection`
6221
- * instance with a new one (in case of migration).
5847
+ * Returns a list of tracks that are currently being published.
5848
+ * @param sdp an optional SDP to extract the `mid` from.
6222
5849
  */
6223
- this.detachEventHandlers = () => {
6224
- this.unregisterOnSubscriberOffer();
6225
- this.unregisterOnIceRestart();
6226
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
6227
- this.pc.removeEventListener('track', this.handleOnTrack);
6228
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
6229
- this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
6230
- this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
5850
+ this.getAnnouncedTracks = (sdp) => {
5851
+ const trackInfos = [];
5852
+ for (const bundle of this.transceiverCache.items()) {
5853
+ const { transceiver, publishOption } = bundle;
5854
+ const track = transceiver.sender.track;
5855
+ if (!track)
5856
+ continue;
5857
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5858
+ }
5859
+ return trackInfos;
6231
5860
  };
6232
5861
  /**
6233
- * Returns the result of the `RTCPeerConnection.getStats()` method
6234
- * @param selector
6235
- * @returns
6236
- */
6237
- this.getStats = (selector) => {
6238
- return this.pc.getStats(selector);
5862
+ * Returns a list of tracks that are currently being published.
5863
+ * This method shall be used for the reconnection flow.
5864
+ * There we shouldn't announce the tracks that have been stopped due to a codec switch.
5865
+ */
5866
+ this.getAnnouncedTracksForReconnect = () => {
5867
+ const sdp = this.pc.localDescription?.sdp;
5868
+ const trackInfos = [];
5869
+ for (const publishOption of this.publishOptions) {
5870
+ const transceiver = this.transceiverCache.get(publishOption);
5871
+ if (!transceiver || !transceiver.sender.track)
5872
+ continue;
5873
+ trackInfos.push(this.toTrackInfo(transceiver, publishOption, sdp));
5874
+ }
5875
+ return trackInfos;
6239
5876
  };
6240
5877
  /**
6241
- * Sets the SFU client to use.
6242
- *
6243
- * @param sfuClient the SFU client to use.
5878
+ * Converts the given transceiver to a `TrackInfo` object.
6244
5879
  */
6245
- this.setSfuClient = (sfuClient) => {
6246
- this.sfuClient = sfuClient;
5880
+ this.toTrackInfo = (transceiver, publishOption, sdp) => {
5881
+ const track = transceiver.sender.track;
5882
+ const isTrackLive = track.readyState === 'live';
5883
+ const layers = isTrackLive
5884
+ ? computeVideoLayers(track, publishOption)
5885
+ : this.transceiverCache.getLayers(publishOption);
5886
+ this.transceiverCache.setLayers(publishOption, layers);
5887
+ const isAudioTrack = isAudioTrackType(publishOption.trackType);
5888
+ const isStereo = isAudioTrack && track.getSettings().channelCount === 2;
5889
+ const transceiverIndex = this.transceiverCache.indexOf(transceiver);
5890
+ const audioSettings = this.state.settings?.audio;
5891
+ return {
5892
+ trackId: track.id,
5893
+ layers: toVideoLayers(layers),
5894
+ trackType: publishOption.trackType,
5895
+ mid: extractMid(transceiver, transceiverIndex, sdp),
5896
+ stereo: isStereo,
5897
+ dtx: isAudioTrack && !!audioSettings?.opus_dtx_enabled,
5898
+ red: isAudioTrack && !!audioSettings?.redundant_coding_enabled,
5899
+ muted: !isTrackLive,
5900
+ codec: publishOption.codec,
5901
+ publishOptionId: publishOption.id,
5902
+ };
6247
5903
  };
5904
+ this.publishOptions = publishOptions;
5905
+ this.pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
5906
+ this.on('iceRestart', (iceRestart) => {
5907
+ if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
5908
+ return;
5909
+ this.restartIce().catch((err) => {
5910
+ this.logger('warn', `ICERestart failed`, err);
5911
+ this.onUnrecoverableError?.();
5912
+ });
5913
+ });
5914
+ this.on('changePublishQuality', async (event) => {
5915
+ for (const videoSender of event.videoSenders) {
5916
+ await this.changePublishQuality(videoSender);
5917
+ }
5918
+ });
5919
+ this.on('changePublishOptions', (event) => {
5920
+ this.publishOptions = event.publishOptions;
5921
+ return this.syncPublishOptions();
5922
+ });
5923
+ }
5924
+ /**
5925
+ * Detaches the event handlers from the `RTCPeerConnection`.
5926
+ * This is useful when we want to replace the `RTCPeerConnection`
5927
+ * instance with a new one (in case of migration).
5928
+ */
5929
+ detachEventHandlers() {
5930
+ super.detachEventHandlers();
5931
+ this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
5932
+ }
5933
+ }
5934
+
5935
+ /**
5936
+ * A wrapper around the `RTCPeerConnection` that handles the incoming
5937
+ * media streams from the SFU.
5938
+ *
5939
+ * @internal
5940
+ */
5941
+ class Subscriber extends BasePeerConnection {
5942
+ /**
5943
+ * Constructs a new `Subscriber` instance.
5944
+ */
5945
+ constructor(opts) {
5946
+ super(PeerType.SUBSCRIBER, opts);
6248
5947
  /**
6249
5948
  * Restarts the ICE connection and renegotiates with the SFU.
6250
5949
  */
@@ -6307,7 +6006,15 @@ class Subscriber {
6307
6006
  this.logger('error', `Unknown track type: ${rawTrackType}`);
6308
6007
  return;
6309
6008
  }
6009
+ // get the previous stream to dispose it later
6010
+ // usually this happens during migration, when the stream is replaced
6011
+ // with a new one but the old one is still in the state
6310
6012
  const previousStream = participantToUpdate[streamKindProp];
6013
+ // replace the previous stream with the new one, prevents flickering
6014
+ this.state.updateParticipant(participantToUpdate.sessionId, {
6015
+ [streamKindProp]: primaryStream,
6016
+ });
6017
+ // now, dispose the previous stream if it exists
6311
6018
  if (previousStream) {
6312
6019
  this.logger('info', `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
6313
6020
  previousStream.getTracks().forEach((t) => {
@@ -6315,24 +6022,6 @@ class Subscriber {
6315
6022
  previousStream.removeTrack(t);
6316
6023
  });
6317
6024
  }
6318
- this.state.updateParticipant(participantToUpdate.sessionId, {
6319
- [streamKindProp]: primaryStream,
6320
- });
6321
- };
6322
- this.onIceCandidate = (e) => {
6323
- const { candidate } = e;
6324
- if (!candidate) {
6325
- this.logger('debug', 'null ice candidate');
6326
- return;
6327
- }
6328
- this.sfuClient
6329
- .iceTrickle({
6330
- iceCandidate: getIceCandidate(candidate),
6331
- peerType: PeerType.SUBSCRIBER,
6332
- })
6333
- .catch((err) => {
6334
- this.logger('warn', `ICETrickle failed`, err);
6335
- });
6336
6025
  };
6337
6026
  this.negotiate = async (subscriberOffer) => {
6338
6027
  this.logger('info', `Received subscriberOffer`, subscriberOffer);
@@ -6340,15 +6029,7 @@ class Subscriber {
6340
6029
  type: 'offer',
6341
6030
  sdp: subscriberOffer.sdp,
6342
6031
  });
6343
- this.sfuClient.iceTrickleBuffer.subscriberCandidates.subscribe(async (candidate) => {
6344
- try {
6345
- const iceCandidate = JSON.parse(candidate.iceCandidate);
6346
- await this.pc.addIceCandidate(iceCandidate);
6347
- }
6348
- catch (e) {
6349
- this.logger('warn', `ICE candidate error`, [e, candidate]);
6350
- }
6351
- });
6032
+ this.addTrickledIceCandidates();
6352
6033
  const answer = await this.pc.createAnswer();
6353
6034
  await this.pc.setLocalDescription(answer);
6354
6035
  await this.sfuClient.sendAnswer({
@@ -6357,56 +6038,21 @@ class Subscriber {
6357
6038
  });
6358
6039
  this.isIceRestarting = false;
6359
6040
  };
6360
- this.onIceConnectionStateChange = () => {
6361
- const state = this.pc.iceConnectionState;
6362
- this.logger('debug', `ICE connection state changed`, state);
6363
- if (this.state.callingState === CallingState.RECONNECTING)
6364
- return;
6365
- // do nothing when ICE is restarting
6366
- if (this.isIceRestarting)
6367
- return;
6368
- if (state === 'failed' || state === 'disconnected') {
6369
- this.logger('debug', `Attempting to restart ICE`);
6370
- this.restartIce().catch((e) => {
6371
- this.logger('error', `ICE restart failed`, e);
6372
- this.onUnrecoverableError?.();
6373
- });
6374
- }
6375
- };
6376
- this.onIceGatheringStateChange = () => {
6377
- this.logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
6378
- };
6379
- this.onIceCandidateError = (e) => {
6380
- const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
6381
- `${e.errorCode}: ${e.errorText}`;
6382
- const iceState = this.pc.iceConnectionState;
6383
- const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
6384
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
6385
- };
6386
- this.logger = getLogger(['Subscriber', logTag]);
6387
- this.sfuClient = sfuClient;
6388
- this.state = state;
6389
- this.onUnrecoverableError = onUnrecoverableError;
6390
- this.pc = this.createPeerConnection(connectionConfig);
6391
- const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
6392
- this.unregisterOnSubscriberOffer = dispatcher.on('subscriberOffer', (subscriberOffer) => {
6393
- withoutConcurrency(subscriberOfferConcurrencyTag, () => {
6394
- return this.negotiate(subscriberOffer);
6395
- }).catch((err) => {
6041
+ this.pc.addEventListener('track', this.handleOnTrack);
6042
+ this.on('subscriberOffer', async (subscriberOffer) => {
6043
+ return this.negotiate(subscriberOffer).catch((err) => {
6396
6044
  this.logger('error', `Negotiation failed.`, err);
6397
6045
  });
6398
6046
  });
6399
- const iceRestartConcurrencyTag = Symbol('iceRestart');
6400
- this.unregisterOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
6401
- withoutConcurrency(iceRestartConcurrencyTag, async () => {
6402
- if (iceRestart.peerType !== PeerType.SUBSCRIBER)
6403
- return;
6404
- await this.restartIce();
6405
- }).catch((err) => {
6406
- this.logger('error', `ICERestart failed`, err);
6407
- this.onUnrecoverableError?.();
6408
- });
6409
- });
6047
+ }
6048
+ /**
6049
+ * Detaches the event handlers from the `RTCPeerConnection`.
6050
+ * This is useful when we want to replace the `RTCPeerConnection`
6051
+ * instance with a new one (in case of migration).
6052
+ */
6053
+ detachEventHandlers() {
6054
+ super.detachEventHandlers();
6055
+ this.pc.removeEventListener('track', this.handleOnTrack);
6410
6056
  }
6411
6057
  }
6412
6058
 
@@ -6438,6 +6084,16 @@ const createWebSocketSignalChannel = (opts) => {
6438
6084
  return ws;
6439
6085
  };
6440
6086
 
6087
+ const toRtcConfiguration = (config) => {
6088
+ return {
6089
+ iceServers: config.map((ice) => ({
6090
+ urls: ice.urls,
6091
+ username: ice.username,
6092
+ credential: ice.password,
6093
+ })),
6094
+ };
6095
+ };
6096
+
6441
6097
  /**
6442
6098
  * Saving a long-lived reference to a promise that can reject can be unsafe,
6443
6099
  * since rejecting the promise causes an unhandled rejection error (even if the
@@ -6722,6 +6378,7 @@ class StreamSfuClient {
6722
6378
  clearTimeout(this.migrateAwayTimeout);
6723
6379
  this.abortController.abort();
6724
6380
  this.migrationTask?.resolve();
6381
+ this.iceTrickleBuffer.dispose();
6725
6382
  };
6726
6383
  this.leaveAndClose = async (reason) => {
6727
6384
  await this.joinTask;
@@ -6754,13 +6411,9 @@ class StreamSfuClient {
6754
6411
  await this.joinTask;
6755
6412
  return retryable(() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6756
6413
  };
6757
- this.updateMuteState = async (trackType, muted) => {
6414
+ this.updateMuteStates = async (muteStates) => {
6758
6415
  await this.joinTask;
6759
- return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
6760
- };
6761
- this.updateMuteStates = async (data) => {
6762
- await this.joinTask;
6763
- return retryable(() => this.rpc.updateMuteStates({ ...data, sessionId: this.sessionId }), this.abortController.signal);
6416
+ return retryable(() => this.rpc.updateMuteStates({ muteStates, sessionId: this.sessionId }), this.abortController.signal);
6764
6417
  };
6765
6418
  this.sendStats = async (stats) => {
6766
6419
  await this.joinTask;
@@ -6940,16 +6593,6 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
6940
6593
  */
6941
6594
  StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
6942
6595
 
6943
- const toRtcConfiguration = (config) => {
6944
- return {
6945
- iceServers: config.map((ice) => ({
6946
- urls: ice.urls,
6947
- username: ice.username,
6948
- credential: ice.password,
6949
- })),
6950
- };
6951
- };
6952
-
6953
6596
  /**
6954
6597
  * Event handler that watched the delivery of `call.accepted`.
6955
6598
  * Once the event is received, the call is joined.
@@ -7168,6 +6811,21 @@ const handleRemoteSoftMute = (call) => {
7168
6811
  });
7169
6812
  };
7170
6813
 
6814
+ /**
6815
+ * Adds unique values to an array.
6816
+ *
6817
+ * @param arr the array to add to.
6818
+ * @param values the values to add.
6819
+ */
6820
+ const pushToIfMissing = (arr, ...values) => {
6821
+ for (const v of values) {
6822
+ if (!arr.includes(v)) {
6823
+ arr.push(v);
6824
+ }
6825
+ }
6826
+ return arr;
6827
+ };
6828
+
7171
6829
  /**
7172
6830
  * An event responder which handles the `participantJoined` event.
7173
6831
  */
@@ -7233,7 +6891,7 @@ const watchTrackPublished = (state) => {
7233
6891
  }
7234
6892
  else {
7235
6893
  state.updateParticipant(sessionId, (p) => ({
7236
- publishedTracks: [...p.publishedTracks, type].filter(unique),
6894
+ publishedTracks: pushToIfMissing([...p.publishedTracks], type),
7237
6895
  }));
7238
6896
  }
7239
6897
  };
@@ -7258,7 +6916,6 @@ const watchTrackUnpublished = (state) => {
7258
6916
  }
7259
6917
  };
7260
6918
  };
7261
- const unique = (v, i, arr) => arr.indexOf(v) === i;
7262
6919
  /**
7263
6920
  * Reconciles orphaned tracks (if any) for the given participant.
7264
6921
  *
@@ -7408,6 +7065,38 @@ const getSdkVersion = (sdk) => {
7408
7065
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7409
7066
  };
7410
7067
 
7068
+ /**
7069
+ * Checks whether the current browser is Safari.
7070
+ */
7071
+ const isSafari = () => {
7072
+ if (typeof navigator === 'undefined')
7073
+ return false;
7074
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
7075
+ };
7076
+ /**
7077
+ * Checks whether the current browser is Firefox.
7078
+ */
7079
+ const isFirefox = () => {
7080
+ if (typeof navigator === 'undefined')
7081
+ return false;
7082
+ return navigator.userAgent?.includes('Firefox');
7083
+ };
7084
+ /**
7085
+ * Checks whether the current browser is Google Chrome.
7086
+ */
7087
+ const isChrome = () => {
7088
+ if (typeof navigator === 'undefined')
7089
+ return false;
7090
+ return navigator.userAgent?.includes('Chrome');
7091
+ };
7092
+
7093
+ var browsers = /*#__PURE__*/Object.freeze({
7094
+ __proto__: null,
7095
+ isChrome: isChrome,
7096
+ isFirefox: isFirefox,
7097
+ isSafari: isSafari
7098
+ });
7099
+
7411
7100
  /**
7412
7101
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
7413
7102
  */
@@ -7424,12 +7113,12 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7424
7113
  return undefined;
7425
7114
  }
7426
7115
  };
7427
- const getStatsForStream = async (kind, mediaStream) => {
7116
+ const getStatsForStream = async (kind, tracks) => {
7428
7117
  const pc = kind === 'subscriber' ? subscriber : publisher;
7429
7118
  if (!pc)
7430
7119
  return [];
7431
7120
  const statsForStream = [];
7432
- for (let track of mediaStream.getTracks()) {
7121
+ for (const track of tracks) {
7433
7122
  const report = await pc.getStats(track);
7434
7123
  const stats = transform(report, {
7435
7124
  // @ts-ignore
@@ -7454,26 +7143,24 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7454
7143
  */
7455
7144
  const run = async () => {
7456
7145
  const participantStats = {};
7457
- const sessionIds = new Set(sessionIdsToTrack);
7458
- if (sessionIds.size > 0) {
7459
- for (let participant of state.participants) {
7146
+ if (sessionIdsToTrack.size > 0) {
7147
+ const sessionIds = new Set(sessionIdsToTrack);
7148
+ for (const participant of state.participants) {
7460
7149
  if (!sessionIds.has(participant.sessionId))
7461
7150
  continue;
7462
- const kind = participant.isLocalParticipant
7463
- ? 'publisher'
7464
- : 'subscriber';
7151
+ const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant;
7152
+ const kind = isLocalParticipant ? 'publisher' : 'subscriber';
7465
7153
  try {
7466
- const mergedStream = new MediaStream([
7467
- ...(participant.videoStream?.getVideoTracks() || []),
7468
- ...(participant.audioStream?.getAudioTracks() || []),
7469
- ]);
7470
- participantStats[participant.sessionId] = await getStatsForStream(kind, mergedStream);
7471
- mergedStream.getTracks().forEach((t) => {
7472
- mergedStream.removeTrack(t);
7473
- });
7154
+ const tracks = isLocalParticipant
7155
+ ? publisher?.getPublishedTracks() || []
7156
+ : [
7157
+ ...(videoStream?.getVideoTracks() || []),
7158
+ ...(audioStream?.getAudioTracks() || []),
7159
+ ];
7160
+ participantStats[sessionId] = await getStatsForStream(kind, tracks);
7474
7161
  }
7475
7162
  catch (e) {
7476
- logger('error', `Failed to collect stats for ${kind} of ${participant.userId}`, e);
7163
+ logger('warn', `Failed to collect ${kind} stats for ${userId}`, e);
7477
7164
  }
7478
7165
  }
7479
7166
  }
@@ -7483,6 +7170,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7483
7170
  .then((report) => transform(report, {
7484
7171
  kind: 'subscriber',
7485
7172
  trackKind: 'video',
7173
+ publisher,
7486
7174
  }))
7487
7175
  .then(aggregate),
7488
7176
  publisher
@@ -7491,6 +7179,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7491
7179
  .then((report) => transform(report, {
7492
7180
  kind: 'publisher',
7493
7181
  trackKind: 'video',
7182
+ publisher,
7494
7183
  }))
7495
7184
  .then(aggregate)
7496
7185
  : getEmptyStats(),
@@ -7539,7 +7228,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
7539
7228
  * @param opts the transform options.
7540
7229
  */
7541
7230
  const transform = (report, opts) => {
7542
- const { trackKind, kind } = opts;
7231
+ const { trackKind, kind, publisher } = opts;
7543
7232
  const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
7544
7233
  const stats = flatten(report);
7545
7234
  const streams = stats
@@ -7555,6 +7244,16 @@ const transform = (report, opts) => {
7555
7244
  s.id === transport.selectedCandidatePairId);
7556
7245
  roundTripTime = candidatePair?.currentRoundTripTime;
7557
7246
  }
7247
+ let trackType;
7248
+ if (kind === 'publisher' && publisher) {
7249
+ const firefox = isFirefox();
7250
+ const mediaSource = stats.find((s) => s.type === 'media-source' &&
7251
+ // Firefox doesn't have mediaSourceId, so we need to guess the media source
7252
+ (firefox ? true : s.id === rtcStreamStats.mediaSourceId));
7253
+ if (mediaSource) {
7254
+ trackType = publisher.getTrackType(mediaSource.trackIdentifier);
7255
+ }
7256
+ }
7558
7257
  return {
7559
7258
  bytesSent: rtcStreamStats.bytesSent,
7560
7259
  bytesReceived: rtcStreamStats.bytesReceived,
@@ -7565,10 +7264,12 @@ const transform = (report, opts) => {
7565
7264
  framesPerSecond: rtcStreamStats.framesPerSecond,
7566
7265
  jitter: rtcStreamStats.jitter,
7567
7266
  kind: rtcStreamStats.kind,
7267
+ mediaSourceId: rtcStreamStats.mediaSourceId,
7568
7268
  // @ts-ignore: available in Chrome only, TS doesn't recognize this
7569
7269
  qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
7570
7270
  rid: rtcStreamStats.rid,
7571
7271
  ssrc: rtcStreamStats.ssrc,
7272
+ trackType,
7572
7273
  };
7573
7274
  });
7574
7275
  return {
@@ -7589,6 +7290,7 @@ const getEmptyStats = (stats) => {
7589
7290
  highestFrameHeight: 0,
7590
7291
  highestFramesPerSecond: 0,
7591
7292
  codec: '',
7293
+ codecPerTrackType: {},
7592
7294
  timestamp: Date.now(),
7593
7295
  };
7594
7296
  };
@@ -7624,6 +7326,12 @@ const aggregate = (stats) => {
7624
7326
  report.averageRoundTripTimeInMs = Math.round((report.averageRoundTripTimeInMs / streams.length) * 1000);
7625
7327
  // we take the first codec we find, as it should be the same for all streams
7626
7328
  report.codec = streams[0].codec || '';
7329
+ report.codecPerTrackType = streams.reduce((acc, stream) => {
7330
+ if (stream.trackType) {
7331
+ acc[stream.trackType] = stream.codec || '';
7332
+ }
7333
+ return acc;
7334
+ }, {});
7627
7335
  }
7628
7336
  const qualityLimitationReason = [
7629
7337
  qualityLimitationReasons.has('cpu') && 'cpu',
@@ -7635,7 +7343,135 @@ const aggregate = (stats) => {
7635
7343
  if (qualityLimitationReason) {
7636
7344
  report.qualityLimitationReasons = qualityLimitationReason;
7637
7345
  }
7638
- return report;
7346
+ return report;
7347
+ };
7348
+
7349
+ const version = "1.15.0";
7350
+ const [major, minor, patch] = version.split('.');
7351
+ let sdkInfo = {
7352
+ type: SdkType.PLAIN_JAVASCRIPT,
7353
+ major,
7354
+ minor,
7355
+ patch,
7356
+ };
7357
+ let osInfo;
7358
+ let deviceInfo;
7359
+ let webRtcInfo;
7360
+ let deviceState = { oneofKind: undefined };
7361
+ const setSdkInfo = (info) => {
7362
+ sdkInfo = info;
7363
+ };
7364
+ const getSdkInfo = () => {
7365
+ return sdkInfo;
7366
+ };
7367
+ const setOSInfo = (info) => {
7368
+ osInfo = info;
7369
+ };
7370
+ const getOSInfo = () => {
7371
+ return osInfo;
7372
+ };
7373
+ const setDeviceInfo = (info) => {
7374
+ deviceInfo = info;
7375
+ };
7376
+ const getDeviceInfo = () => {
7377
+ return deviceInfo;
7378
+ };
7379
+ const getWebRTCInfo = () => {
7380
+ return webRtcInfo;
7381
+ };
7382
+ const setWebRTCInfo = (info) => {
7383
+ webRtcInfo = info;
7384
+ };
7385
+ const setThermalState = (state) => {
7386
+ if (!osInfo) {
7387
+ deviceState = { oneofKind: undefined };
7388
+ return;
7389
+ }
7390
+ if (osInfo.name === 'android') {
7391
+ const thermalState = AndroidThermalState[state] ||
7392
+ AndroidThermalState.UNSPECIFIED;
7393
+ deviceState = {
7394
+ oneofKind: 'android',
7395
+ android: {
7396
+ thermalState,
7397
+ isPowerSaverMode: deviceState?.oneofKind === 'android' &&
7398
+ deviceState.android.isPowerSaverMode,
7399
+ },
7400
+ };
7401
+ }
7402
+ if (osInfo.name.toLowerCase() === 'ios') {
7403
+ const thermalState = AppleThermalState[state] ||
7404
+ AppleThermalState.UNSPECIFIED;
7405
+ deviceState = {
7406
+ oneofKind: 'apple',
7407
+ apple: {
7408
+ thermalState,
7409
+ isLowPowerModeEnabled: deviceState?.oneofKind === 'apple' &&
7410
+ deviceState.apple.isLowPowerModeEnabled,
7411
+ },
7412
+ };
7413
+ }
7414
+ };
7415
+ const setPowerState = (powerMode) => {
7416
+ if (!osInfo) {
7417
+ deviceState = { oneofKind: undefined };
7418
+ return;
7419
+ }
7420
+ if (osInfo.name === 'android') {
7421
+ deviceState = {
7422
+ oneofKind: 'android',
7423
+ android: {
7424
+ thermalState: deviceState?.oneofKind === 'android'
7425
+ ? deviceState.android.thermalState
7426
+ : AndroidThermalState.UNSPECIFIED,
7427
+ isPowerSaverMode: powerMode,
7428
+ },
7429
+ };
7430
+ }
7431
+ if (osInfo.name.toLowerCase() === 'ios') {
7432
+ deviceState = {
7433
+ oneofKind: 'apple',
7434
+ apple: {
7435
+ thermalState: deviceState?.oneofKind === 'apple'
7436
+ ? deviceState.apple.thermalState
7437
+ : AppleThermalState.UNSPECIFIED,
7438
+ isLowPowerModeEnabled: powerMode,
7439
+ },
7440
+ };
7441
+ }
7442
+ };
7443
+ const getDeviceState = () => {
7444
+ return deviceState;
7445
+ };
7446
+ const getClientDetails = () => {
7447
+ if (isReactNative()) {
7448
+ // Since RN doesn't support web, sharing browser info is not required
7449
+ return {
7450
+ sdk: getSdkInfo(),
7451
+ os: getOSInfo(),
7452
+ device: getDeviceInfo(),
7453
+ };
7454
+ }
7455
+ const userAgent = new UAParser(navigator.userAgent);
7456
+ const { browser, os, device, cpu } = userAgent.getResult();
7457
+ return {
7458
+ sdk: getSdkInfo(),
7459
+ browser: {
7460
+ name: browser.name || navigator.userAgent,
7461
+ version: browser.version || '',
7462
+ },
7463
+ os: {
7464
+ name: os.name || '',
7465
+ version: os.version || '',
7466
+ architecture: cpu.architecture || '',
7467
+ },
7468
+ device: {
7469
+ name: [device.vendor, device.model, device.type]
7470
+ .filter(Boolean)
7471
+ .join(' '),
7472
+ version: '',
7473
+ },
7474
+ };
7639
7475
  };
7640
7476
 
7641
7477
  class SfuStatsReporter {
@@ -7671,8 +7507,28 @@ class SfuStatsReporter {
7671
7507
  });
7672
7508
  });
7673
7509
  };
7674
- this.sendTelemetryData = async (telemetryData) => {
7675
- return this.run(telemetryData);
7510
+ this.sendConnectionTime = (connectionTimeSeconds) => {
7511
+ this.sendTelemetryData({
7512
+ data: {
7513
+ oneofKind: 'connectionTimeSeconds',
7514
+ connectionTimeSeconds,
7515
+ },
7516
+ });
7517
+ };
7518
+ this.sendReconnectionTime = (strategy, timeSeconds) => {
7519
+ this.sendTelemetryData({
7520
+ data: {
7521
+ oneofKind: 'reconnection',
7522
+ reconnection: { strategy, timeSeconds },
7523
+ },
7524
+ });
7525
+ };
7526
+ this.sendTelemetryData = (telemetryData) => {
7527
+ // intentionally not awaiting the promise here
7528
+ // to avoid impeding with the ongoing actions.
7529
+ this.run(telemetryData).catch((err) => {
7530
+ this.logger('warn', 'Failed to send telemetry data', err);
7531
+ });
7676
7532
  };
7677
7533
  this.run = async (telemetryData) => {
7678
7534
  const [subscriberStats, publisherStats] = await Promise.all([
@@ -8240,6 +8096,25 @@ class PermissionsContext {
8240
8096
  this.hasPermission = (permission) => {
8241
8097
  return this.permissions.includes(permission);
8242
8098
  };
8099
+ /**
8100
+ * Helper method that checks whether the current user has the permission
8101
+ * to publish the given track type.
8102
+ */
8103
+ this.canPublish = (trackType) => {
8104
+ switch (trackType) {
8105
+ case TrackType.AUDIO:
8106
+ return this.hasPermission(OwnCapability.SEND_AUDIO);
8107
+ case TrackType.VIDEO:
8108
+ return this.hasPermission(OwnCapability.SEND_VIDEO);
8109
+ case TrackType.SCREEN_SHARE:
8110
+ case TrackType.SCREEN_SHARE_AUDIO:
8111
+ return this.hasPermission(OwnCapability.SCREENSHARE);
8112
+ case TrackType.UNSPECIFIED:
8113
+ return false;
8114
+ default:
8115
+ ensureExhausted(trackType, 'Unknown track type');
8116
+ }
8117
+ };
8243
8118
  /**
8244
8119
  * Checks if the current user can request a specific permission
8245
8120
  * within the call.
@@ -8878,36 +8753,42 @@ class InputMediaDeviceManager {
8878
8753
  }
8879
8754
  });
8880
8755
  }
8756
+ publishStream(stream) {
8757
+ return this.call.publish(stream, this.trackType);
8758
+ }
8759
+ stopPublishStream() {
8760
+ return this.call.stopPublish(this.trackType);
8761
+ }
8881
8762
  getTracks() {
8882
8763
  return this.state.mediaStream?.getTracks() ?? [];
8883
8764
  }
8884
8765
  async muteStream(stopTracks = true) {
8885
- if (!this.state.mediaStream)
8766
+ const mediaStream = this.state.mediaStream;
8767
+ if (!mediaStream)
8886
8768
  return;
8887
8769
  this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
8888
8770
  if (this.call.state.callingState === CallingState.JOINED) {
8889
- await this.stopPublishStream(stopTracks);
8771
+ await this.stopPublishStream();
8890
8772
  }
8891
8773
  this.muteLocalStream(stopTracks);
8892
8774
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
8893
8775
  if (allEnded) {
8894
- if (this.state.mediaStream &&
8895
- // @ts-expect-error release() is present in react-native-webrtc
8896
- typeof this.state.mediaStream.release === 'function') {
8776
+ // @ts-expect-error release() is present in react-native-webrtc
8777
+ if (typeof mediaStream.release === 'function') {
8897
8778
  // @ts-expect-error called to dispose the stream in RN
8898
- this.state.mediaStream.release();
8779
+ mediaStream.release();
8899
8780
  }
8900
8781
  this.state.setMediaStream(undefined, undefined);
8901
8782
  this.filters.forEach((entry) => entry.stop?.());
8902
8783
  }
8903
8784
  }
8904
- muteTracks() {
8785
+ disableTracks() {
8905
8786
  this.getTracks().forEach((track) => {
8906
8787
  if (track.enabled)
8907
8788
  track.enabled = false;
8908
8789
  });
8909
8790
  }
8910
- unmuteTracks() {
8791
+ enableTracks() {
8911
8792
  this.getTracks().forEach((track) => {
8912
8793
  if (!track.enabled)
8913
8794
  track.enabled = true;
@@ -8927,7 +8808,7 @@ class InputMediaDeviceManager {
8927
8808
  this.stopTracks();
8928
8809
  }
8929
8810
  else {
8930
- this.muteTracks();
8811
+ this.disableTracks();
8931
8812
  }
8932
8813
  }
8933
8814
  async unmuteStream() {
@@ -8937,7 +8818,7 @@ class InputMediaDeviceManager {
8937
8818
  if (this.state.mediaStream &&
8938
8819
  this.getTracks().every((t) => t.readyState === 'live')) {
8939
8820
  stream = this.state.mediaStream;
8940
- this.unmuteTracks();
8821
+ this.enableTracks();
8941
8822
  }
8942
8823
  else {
8943
8824
  const defaultConstraints = this.state.defaultConstraints;
@@ -9031,9 +8912,22 @@ class InputMediaDeviceManager {
9031
8912
  await this.disable();
9032
8913
  }
9033
8914
  };
9034
- this.getTracks().forEach((track) => {
8915
+ const createTrackMuteHandler = (muted) => () => {
8916
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
8917
+ this.logger('warn', 'Error while notifying track mute state', err);
8918
+ });
8919
+ };
8920
+ stream.getTracks().forEach((track) => {
8921
+ const muteHandler = createTrackMuteHandler(true);
8922
+ const unmuteHandler = createTrackMuteHandler(false);
8923
+ track.addEventListener('mute', muteHandler);
8924
+ track.addEventListener('unmute', unmuteHandler);
9035
8925
  track.addEventListener('ended', handleTrackEnded);
9036
- this.subscriptions.push(() => track.removeEventListener('ended', handleTrackEnded));
8926
+ this.subscriptions.push(() => {
8927
+ track.removeEventListener('mute', muteHandler);
8928
+ track.removeEventListener('unmute', unmuteHandler);
8929
+ track.removeEventListener('ended', handleTrackEnded);
8930
+ });
9037
8931
  });
9038
8932
  }
9039
8933
  }
@@ -9057,8 +8951,8 @@ class InputMediaDeviceManager {
9057
8951
  await this.statusChangeSettled();
9058
8952
  let isDeviceDisconnected = false;
9059
8953
  let isDeviceReplaced = false;
9060
- const currentDevice = this.findDeviceInList(currentDevices, deviceId);
9061
- const prevDevice = this.findDeviceInList(prevDevices, deviceId);
8954
+ const currentDevice = this.findDevice(currentDevices, deviceId);
8955
+ const prevDevice = this.findDevice(prevDevices, deviceId);
9062
8956
  if (!currentDevice && prevDevice) {
9063
8957
  isDeviceDisconnected = true;
9064
8958
  }
@@ -9088,8 +8982,9 @@ class InputMediaDeviceManager {
9088
8982
  }
9089
8983
  }));
9090
8984
  }
9091
- findDeviceInList(devices, deviceId) {
9092
- return devices.find((d) => d.deviceId === deviceId && d.kind === this.mediaDeviceKind);
8985
+ findDevice(devices, deviceId) {
8986
+ const kind = this.mediaDeviceKind;
8987
+ return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
9093
8988
  }
9094
8989
  }
9095
8990
 
@@ -9353,14 +9248,35 @@ class CameraManager extends InputMediaDeviceManager {
9353
9248
  }
9354
9249
  }
9355
9250
  /**
9356
- * Sets the preferred codec for encoding the video.
9251
+ * Applies the video settings to the camera.
9357
9252
  *
9358
- * @internal internal use only, not part of the public API.
9359
- * @deprecated use {@link call.updatePublishOptions} instead.
9360
- * @param codec the codec to use for encoding the video.
9253
+ * @param settings the video settings to apply.
9254
+ * @param publish whether to publish the stream after applying the settings.
9361
9255
  */
9362
- setPreferredCodec(codec) {
9363
- this.call.updatePublishOptions({ preferredCodec: codec });
9256
+ async apply(settings, publish) {
9257
+ const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
9258
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9259
+ if (hasPublishedVideo || !hasPermission)
9260
+ return;
9261
+ // Wait for any in progress camera operation
9262
+ await this.statusChangeSettled();
9263
+ const { target_resolution, camera_facing, camera_default_on } = settings;
9264
+ await this.selectTargetResolution(target_resolution);
9265
+ // Set camera direction if it's not yet set
9266
+ if (!this.state.direction && !this.state.selectedDevice) {
9267
+ this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
9268
+ }
9269
+ if (!publish)
9270
+ return;
9271
+ const { mediaStream } = this.state;
9272
+ if (this.enabled && mediaStream) {
9273
+ // The camera is already enabled (e.g. lobby screen). Publish the stream
9274
+ await this.publishStream(mediaStream);
9275
+ }
9276
+ else if (this.state.status === undefined && camera_default_on) {
9277
+ // Start camera if backend config specifies, and there is no local setting
9278
+ await this.enable();
9279
+ }
9364
9280
  }
9365
9281
  getDevices() {
9366
9282
  return getVideoDevices();
@@ -9378,12 +9294,6 @@ class CameraManager extends InputMediaDeviceManager {
9378
9294
  }
9379
9295
  return getVideoStream(constraints);
9380
9296
  }
9381
- publishStream(stream) {
9382
- return this.call.publishVideoStream(stream);
9383
- }
9384
- stopPublishStream(stopTracks) {
9385
- return this.call.stopPublish(TrackType.VIDEO, stopTracks);
9386
- }
9387
9297
  }
9388
9298
 
9389
9299
  class MicrophoneManagerState extends InputMediaDeviceManagerState {
@@ -9711,18 +9621,37 @@ class MicrophoneManager extends InputMediaDeviceManager {
9711
9621
  this.speakingWhileMutedNotificationEnabled = false;
9712
9622
  await this.stopSpeakingWhileMutedDetection();
9713
9623
  }
9624
+ /**
9625
+ * Applies the audio settings to the microphone.
9626
+ * @param settings the audio settings to apply.
9627
+ * @param publish whether to publish the stream after applying the settings.
9628
+ */
9629
+ async apply(settings, publish) {
9630
+ if (!publish)
9631
+ return;
9632
+ const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
9633
+ const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
9634
+ if (hasPublishedAudio || !hasPermission)
9635
+ return;
9636
+ // Wait for any in progress mic operation
9637
+ await this.statusChangeSettled();
9638
+ // Publish media stream that was set before we joined
9639
+ const { mediaStream } = this.state;
9640
+ if (this.enabled && mediaStream) {
9641
+ // The mic is already enabled (e.g. lobby screen). Publish the stream
9642
+ await this.publishStream(mediaStream);
9643
+ }
9644
+ else if (this.state.status === undefined && settings.mic_default_on) {
9645
+ // Start mic if backend config specifies, and there is no local setting
9646
+ await this.enable();
9647
+ }
9648
+ }
9714
9649
  getDevices() {
9715
9650
  return getAudioDevices();
9716
9651
  }
9717
9652
  getStream(constraints) {
9718
9653
  return getAudioStream(constraints);
9719
9654
  }
9720
- publishStream(stream) {
9721
- return this.call.publishAudioStream(stream);
9722
- }
9723
- stopPublishStream(stopTracks) {
9724
- return this.call.stopPublish(TrackType.AUDIO, stopTracks);
9725
- }
9726
9655
  async startSpeakingWhileMutedDetection(deviceId) {
9727
9656
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
9728
9657
  await this.stopSpeakingWhileMutedDetection();
@@ -9842,7 +9771,7 @@ class ScreenShareManager extends InputMediaDeviceManager {
9842
9771
  async disableScreenShareAudio() {
9843
9772
  this.state.setAudioEnabled(false);
9844
9773
  if (this.call.publisher?.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
9845
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, true);
9774
+ await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO);
9846
9775
  }
9847
9776
  }
9848
9777
  /**
@@ -9868,12 +9797,8 @@ class ScreenShareManager extends InputMediaDeviceManager {
9868
9797
  }
9869
9798
  return getScreenShareStream(constraints);
9870
9799
  }
9871
- publishStream(stream) {
9872
- return this.call.publishScreenShareStream(stream);
9873
- }
9874
- async stopPublishStream(stopTracks) {
9875
- await this.call.stopPublish(TrackType.SCREEN_SHARE, stopTracks);
9876
- await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, stopTracks);
9800
+ async stopPublishStream() {
9801
+ return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
9877
9802
  }
9878
9803
  /**
9879
9804
  * Overrides the default `select` method to throw an error.
@@ -10083,6 +10008,112 @@ class Call {
10083
10008
  */
10084
10009
  this.leaveCallHooks = new Set();
10085
10010
  this.streamClientEventHandlers = new Map();
10011
+ this.setup = async () => {
10012
+ await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
10013
+ if (this.initialized)
10014
+ return;
10015
+ this.leaveCallHooks.add(this.on('all', (event) => {
10016
+ // update state with the latest event data
10017
+ this.state.updateFromEvent(event);
10018
+ }));
10019
+ this.leaveCallHooks.add(this.on('changePublishOptions', (event) => {
10020
+ this.currentPublishOptions = event.publishOptions;
10021
+ }));
10022
+ this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
10023
+ this.registerEffects();
10024
+ this.registerReconnectHandlers();
10025
+ if (this.state.callingState === CallingState.LEFT) {
10026
+ this.state.setCallingState(CallingState.IDLE);
10027
+ }
10028
+ this.initialized = true;
10029
+ });
10030
+ };
10031
+ this.registerEffects = () => {
10032
+ this.leaveCallHooks.add(
10033
+ // handles updating the permissions context when the settings change.
10034
+ createSubscription(this.state.settings$, (settings) => {
10035
+ if (!settings)
10036
+ return;
10037
+ this.permissionsContext.setCallSettings(settings);
10038
+ }));
10039
+ this.leaveCallHooks.add(
10040
+ // handle the case when the user permissions are modified.
10041
+ createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
10042
+ this.leaveCallHooks.add(
10043
+ // handles the case when the user is blocked by the call owner.
10044
+ createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
10045
+ if (!blockedUserIds || blockedUserIds.length === 0)
10046
+ return;
10047
+ const currentUserId = this.currentUserId;
10048
+ if (currentUserId && blockedUserIds.includes(currentUserId)) {
10049
+ this.logger('info', 'Leaving call because of being blocked');
10050
+ await this.leave({ reason: 'user blocked' }).catch((err) => {
10051
+ this.logger('error', 'Error leaving call after being blocked', err);
10052
+ });
10053
+ }
10054
+ }));
10055
+ this.leaveCallHooks.add(
10056
+ // cancel auto-drop when call is
10057
+ createSubscription(this.state.session$, (session) => {
10058
+ if (!this.ringing)
10059
+ return;
10060
+ const receiverId = this.clientStore.connectedUser?.id;
10061
+ if (!receiverId)
10062
+ return;
10063
+ const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
10064
+ const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
10065
+ if (isAcceptedByMe || isRejectedByMe) {
10066
+ this.cancelAutoDrop();
10067
+ }
10068
+ }));
10069
+ this.leaveCallHooks.add(
10070
+ // "ringing" mode effects and event handlers
10071
+ createSubscription(this.ringingSubject, (isRinging) => {
10072
+ if (!isRinging)
10073
+ return;
10074
+ const callSession = this.state.session;
10075
+ const receiver_id = this.clientStore.connectedUser?.id;
10076
+ const ended_at = callSession?.ended_at;
10077
+ const created_by_id = this.state.createdBy?.id;
10078
+ const rejected_by = callSession?.rejected_by;
10079
+ const accepted_by = callSession?.accepted_by;
10080
+ let leaveCallIdle = false;
10081
+ if (ended_at) {
10082
+ // call was ended before it was accepted or rejected so we should leave it to idle
10083
+ leaveCallIdle = true;
10084
+ }
10085
+ else if (created_by_id && rejected_by) {
10086
+ if (rejected_by[created_by_id]) {
10087
+ // call was cancelled by the caller
10088
+ leaveCallIdle = true;
10089
+ }
10090
+ }
10091
+ else if (receiver_id && rejected_by) {
10092
+ if (rejected_by[receiver_id]) {
10093
+ // call was rejected by the receiver in some other device
10094
+ leaveCallIdle = true;
10095
+ }
10096
+ }
10097
+ else if (receiver_id && accepted_by) {
10098
+ if (accepted_by[receiver_id]) {
10099
+ // call was accepted by the receiver in some other device
10100
+ leaveCallIdle = true;
10101
+ }
10102
+ }
10103
+ if (leaveCallIdle) {
10104
+ if (this.state.callingState !== CallingState.IDLE) {
10105
+ this.state.setCallingState(CallingState.IDLE);
10106
+ }
10107
+ }
10108
+ else {
10109
+ if (this.state.callingState === CallingState.IDLE) {
10110
+ this.state.setCallingState(CallingState.RINGING);
10111
+ }
10112
+ this.scheduleAutoDrop();
10113
+ this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
10114
+ }
10115
+ }));
10116
+ };
10086
10117
  this.handleOwnCapabilitiesUpdated = async (ownCapabilities) => {
10087
10118
  // update the permission context.
10088
10119
  this.permissionsContext.setPermissions(ownCapabilities);
@@ -10195,14 +10226,15 @@ class Call {
10195
10226
  this.statsReporter = undefined;
10196
10227
  this.sfuStatsReporter?.stop();
10197
10228
  this.sfuStatsReporter = undefined;
10198
- this.subscriber?.close();
10229
+ this.subscriber?.dispose();
10199
10230
  this.subscriber = undefined;
10200
- this.publisher?.close({ stopTracks: true });
10231
+ this.publisher?.dispose();
10201
10232
  this.publisher = undefined;
10202
10233
  await this.sfuClient?.leaveAndClose(reason);
10203
10234
  this.sfuClient = undefined;
10204
10235
  this.dynascaleManager.setSfuClient(undefined);
10205
10236
  this.state.setCallingState(CallingState.LEFT);
10237
+ this.state.dispose();
10206
10238
  // Call all leave call hooks, e.g. to clean up global event handlers
10207
10239
  this.leaveCallHooks.forEach((hook) => hook());
10208
10240
  this.initialized = false;
@@ -10234,7 +10266,8 @@ class Call {
10234
10266
  // call.ring event excludes the call creator in the members list
10235
10267
  // as the creator does not get the ring event
10236
10268
  // so update the member list accordingly
10237
- const creator = this.state.members.find((m) => m.user.id === event.call.created_by.id);
10269
+ const { created_by, settings } = event.call;
10270
+ const creator = this.state.members.find((m) => m.user.id === created_by.id);
10238
10271
  if (!creator) {
10239
10272
  this.state.setMembers(event.members);
10240
10273
  }
@@ -10249,7 +10282,7 @@ class Call {
10249
10282
  // const calls = useCalls().filter((c) => c.ringing);
10250
10283
  const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
10251
10284
  this.clientStore.setCalls([this, ...calls]);
10252
- await this.applyDeviceConfig(false);
10285
+ await this.applyDeviceConfig(settings, false);
10253
10286
  };
10254
10287
  /**
10255
10288
  * Loads the information about the call.
@@ -10272,7 +10305,7 @@ class Call {
10272
10305
  this.watching = true;
10273
10306
  this.clientStore.registerCall(this);
10274
10307
  }
10275
- await this.applyDeviceConfig(false);
10308
+ await this.applyDeviceConfig(response.call.settings, false);
10276
10309
  return response;
10277
10310
  };
10278
10311
  /**
@@ -10294,7 +10327,7 @@ class Call {
10294
10327
  this.watching = true;
10295
10328
  this.clientStore.registerCall(this);
10296
10329
  }
10297
- await this.applyDeviceConfig(false);
10330
+ await this.applyDeviceConfig(response.call.settings, false);
10298
10331
  return response;
10299
10332
  };
10300
10333
  /**
@@ -10396,19 +10429,32 @@ class Call {
10396
10429
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
10397
10430
  if (previousSfuClient !== sfuClient) {
10398
10431
  // prepare a generic SDP and send it to the SFU.
10399
- // this is a throw-away SDP that the SFU will use to determine
10432
+ // these are throw-away SDPs that the SFU will use to determine
10400
10433
  // the capabilities of the client (codec support, etc.)
10401
- const receivingCapabilitiesSdp = await getGenericSdp('recvonly');
10402
- const reconnectDetails = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED
10434
+ const [subscriberSdp, publisherSdp] = await Promise.all([
10435
+ getGenericSdp('recvonly'),
10436
+ getGenericSdp('sendonly'),
10437
+ ]);
10438
+ const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
10439
+ const reconnectDetails = isReconnecting
10403
10440
  ? this.getReconnectDetails(data?.migrating_from, previousSessionId)
10404
10441
  : undefined;
10405
- const { callState, fastReconnectDeadlineSeconds } = await sfuClient.join({
10406
- subscriberSdp: receivingCapabilitiesSdp,
10407
- publisherSdp: '',
10442
+ const preferredPublishOptions = !isReconnecting
10443
+ ? this.getPreferredPublishOptions()
10444
+ : this.currentPublishOptions || [];
10445
+ const preferredSubscribeOptions = !isReconnecting
10446
+ ? this.getPreferredSubscribeOptions()
10447
+ : [];
10448
+ const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
10449
+ subscriberSdp,
10450
+ publisherSdp,
10408
10451
  clientDetails,
10409
10452
  fastReconnect: performingFastReconnect,
10410
10453
  reconnectDetails,
10454
+ preferredPublishOptions,
10455
+ preferredSubscribeOptions,
10411
10456
  });
10457
+ this.currentPublishOptions = publishOptions;
10412
10458
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
10413
10459
  if (callState) {
10414
10460
  this.state.updateFromSfuCallState(callState, sfuClient.sessionId, reconnectDetails);
@@ -10433,17 +10479,13 @@ class Call {
10433
10479
  connectionConfig,
10434
10480
  clientDetails,
10435
10481
  statsOptions,
10482
+ publishOptions: this.currentPublishOptions || [],
10436
10483
  closePreviousInstances: !performingMigration,
10437
10484
  });
10438
10485
  }
10439
10486
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
10440
10487
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
10441
- this.sfuStatsReporter?.sendTelemetryData({
10442
- data: {
10443
- oneofKind: 'connectionTimeSeconds',
10444
- connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
10445
- },
10446
- });
10488
+ this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
10447
10489
  }
10448
10490
  if (performingRejoin) {
10449
10491
  const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
@@ -10454,8 +10496,8 @@ class Call {
10454
10496
  }
10455
10497
  // device settings should be applied only once, we don't have to
10456
10498
  // re-apply them on later reconnections or server-side data fetches
10457
- if (!this.deviceSettingsAppliedOnce) {
10458
- await this.applyDeviceConfig(true);
10499
+ if (!this.deviceSettingsAppliedOnce && this.state.settings) {
10500
+ await this.applyDeviceConfig(this.state.settings, true);
10459
10501
  this.deviceSettingsAppliedOnce = true;
10460
10502
  }
10461
10503
  // We shouldn't persist the `ring` and `notify` state after joining the call
@@ -10464,6 +10506,8 @@ class Call {
10464
10506
  // we will spam the other participants with push notifications and `call.ring` events.
10465
10507
  delete this.joinCallData?.ring;
10466
10508
  delete this.joinCallData?.notify;
10509
+ // reset the reconnect strategy to unspecified after a successful reconnection
10510
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
10467
10511
  this.logger('info', `Joined call ${this.cid}`);
10468
10512
  };
10469
10513
  /**
@@ -10473,7 +10517,7 @@ class Call {
10473
10517
  this.getReconnectDetails = (migratingFromSfuId, previousSessionId) => {
10474
10518
  const strategy = this.reconnectStrategy;
10475
10519
  const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
10476
- const announcedTracks = this.publisher?.getAnnouncedTracks() || [];
10520
+ const announcedTracks = this.publisher?.getAnnouncedTracksForReconnect() || [];
10477
10521
  return {
10478
10522
  strategy,
10479
10523
  announcedTracks,
@@ -10483,6 +10527,54 @@ class Call {
10483
10527
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
10484
10528
  };
10485
10529
  };
10530
+ /**
10531
+ * Prepares the preferred codec for the call.
10532
+ * This is an experimental client feature and subject to change.
10533
+ * @internal
10534
+ */
10535
+ this.getPreferredPublishOptions = () => {
10536
+ const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } = this.clientPublishOptions || {};
10537
+ if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers)
10538
+ return [];
10539
+ const codec = preferredCodec
10540
+ ? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
10541
+ : undefined;
10542
+ const preferredPublishOptions = [
10543
+ PublishOption.create({
10544
+ trackType: TrackType.VIDEO,
10545
+ codec,
10546
+ bitrate: preferredBitrate,
10547
+ maxSpatialLayers: maxSimulcastLayers,
10548
+ }),
10549
+ ];
10550
+ const screenShareSettings = this.screenShare.getSettings();
10551
+ if (screenShareSettings) {
10552
+ preferredPublishOptions.push(PublishOption.create({
10553
+ trackType: TrackType.SCREEN_SHARE,
10554
+ fps: screenShareSettings.maxFramerate,
10555
+ bitrate: screenShareSettings.maxBitrate,
10556
+ }));
10557
+ }
10558
+ return preferredPublishOptions;
10559
+ };
10560
+ /**
10561
+ * Prepares the preferred options for subscribing to tracks.
10562
+ * This is an experimental client feature and subject to change.
10563
+ * @internal
10564
+ */
10565
+ this.getPreferredSubscribeOptions = () => {
10566
+ const { subscriberCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
10567
+ if (!subscriberCodec || !subscriberFmtpLine)
10568
+ return [];
10569
+ return [
10570
+ SubscribeOption.create({
10571
+ trackType: TrackType.VIDEO,
10572
+ codecs: [
10573
+ { name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
10574
+ ],
10575
+ }),
10576
+ ];
10577
+ };
10486
10578
  /**
10487
10579
  * Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
10488
10580
  * Uses the provided SFU client to restore the ICE connection.
@@ -10513,9 +10605,9 @@ class Call {
10513
10605
  * @internal
10514
10606
  */
10515
10607
  this.initPublisherAndSubscriber = (opts) => {
10516
- const { sfuClient, connectionConfig, clientDetails, statsOptions, closePreviousInstances, } = opts;
10608
+ const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, } = opts;
10517
10609
  if (closePreviousInstances && this.subscriber) {
10518
- this.subscriber.close();
10610
+ this.subscriber.dispose();
10519
10611
  }
10520
10612
  this.subscriber = new Subscriber({
10521
10613
  sfuClient,
@@ -10534,18 +10626,14 @@ class Call {
10534
10626
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
10535
10627
  if (!isAnonymous) {
10536
10628
  if (closePreviousInstances && this.publisher) {
10537
- this.publisher.close({ stopTracks: false });
10629
+ this.publisher.dispose();
10538
10630
  }
10539
- const audioSettings = this.state.settings?.audio;
10540
- const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
10541
- const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
10542
10631
  this.publisher = new Publisher({
10543
10632
  sfuClient,
10544
10633
  dispatcher: this.dispatcher,
10545
10634
  state: this.state,
10546
10635
  connectionConfig,
10547
- isDtxEnabled,
10548
- isRedEnabled,
10636
+ publishOptions,
10549
10637
  logTag: String(this.sfuClientTag),
10550
10638
  onUnrecoverableError: () => {
10551
10639
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -10692,47 +10780,31 @@ class Call {
10692
10780
  * @internal
10693
10781
  */
10694
10782
  this.reconnectFast = async () => {
10695
- let reconnectStartTime = Date.now();
10783
+ const reconnectStartTime = Date.now();
10696
10784
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
10697
10785
  this.state.setCallingState(CallingState.RECONNECTING);
10698
10786
  await this.join(this.joinCallData);
10699
- this.sfuStatsReporter?.sendTelemetryData({
10700
- data: {
10701
- oneofKind: 'reconnection',
10702
- reconnection: {
10703
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10704
- strategy: WebsocketReconnectStrategy.FAST,
10705
- },
10706
- },
10707
- });
10787
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
10708
10788
  };
10709
10789
  /**
10710
10790
  * Initiates the reconnection flow with the "rejoin" strategy.
10711
10791
  * @internal
10712
10792
  */
10713
10793
  this.reconnectRejoin = async () => {
10714
- let reconnectStartTime = Date.now();
10794
+ const reconnectStartTime = Date.now();
10715
10795
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
10716
10796
  this.state.setCallingState(CallingState.RECONNECTING);
10717
10797
  await this.join(this.joinCallData);
10718
10798
  await this.restorePublishedTracks();
10719
10799
  this.restoreSubscribedTracks();
10720
- this.sfuStatsReporter?.sendTelemetryData({
10721
- data: {
10722
- oneofKind: 'reconnection',
10723
- reconnection: {
10724
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10725
- strategy: WebsocketReconnectStrategy.REJOIN,
10726
- },
10727
- },
10728
- });
10800
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
10729
10801
  };
10730
10802
  /**
10731
10803
  * Initiates the reconnection flow with the "migrate" strategy.
10732
10804
  * @internal
10733
10805
  */
10734
10806
  this.reconnectMigrate = async () => {
10735
- let reconnectStartTime = Date.now();
10807
+ const reconnectStartTime = Date.now();
10736
10808
  const currentSfuClient = this.sfuClient;
10737
10809
  if (!currentSfuClient) {
10738
10810
  throw new Error('Cannot migrate without an active SFU client');
@@ -10766,20 +10838,12 @@ class Call {
10766
10838
  this.state.setCallingState(CallingState.JOINED);
10767
10839
  }
10768
10840
  finally {
10769
- currentSubscriber?.close();
10770
- currentPublisher?.close({ stopTracks: false });
10841
+ currentSubscriber?.dispose();
10842
+ currentPublisher?.dispose();
10771
10843
  // and close the previous SFU client, without specifying close code
10772
10844
  currentSfuClient.close();
10773
10845
  }
10774
- this.sfuStatsReporter?.sendTelemetryData({
10775
- data: {
10776
- oneofKind: 'reconnection',
10777
- reconnection: {
10778
- timeSeconds: (Date.now() - reconnectStartTime) / 1000,
10779
- strategy: WebsocketReconnectStrategy.MIGRATE,
10780
- },
10781
- },
10782
- });
10846
+ this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.MIGRATE, (Date.now() - reconnectStartTime) / 1000);
10783
10847
  };
10784
10848
  /**
10785
10849
  * Registers the various event handlers for reconnection.
@@ -10856,23 +10920,16 @@ class Call {
10856
10920
  // the tracks need to be restored in their original order of publishing
10857
10921
  // otherwise, we might get `m-lines order mismatch` errors
10858
10922
  for (const trackType of this.trackPublishOrder) {
10923
+ let mediaStream;
10859
10924
  switch (trackType) {
10860
10925
  case TrackType.AUDIO:
10861
- const audioStream = this.microphone.state.mediaStream;
10862
- if (audioStream) {
10863
- await this.publishAudioStream(audioStream);
10864
- }
10926
+ mediaStream = this.microphone.state.mediaStream;
10865
10927
  break;
10866
10928
  case TrackType.VIDEO:
10867
- const videoStream = this.camera.state.mediaStream;
10868
- if (videoStream)
10869
- await this.publishVideoStream(videoStream);
10929
+ mediaStream = this.camera.state.mediaStream;
10870
10930
  break;
10871
10931
  case TrackType.SCREEN_SHARE:
10872
- const screenShareStream = this.screenShare.state.mediaStream;
10873
- if (screenShareStream) {
10874
- await this.publishScreenShareStream(screenShareStream);
10875
- }
10932
+ mediaStream = this.screenShare.state.mediaStream;
10876
10933
  break;
10877
10934
  // screen share audio can't exist without a screen share, so we handle it there
10878
10935
  case TrackType.SCREEN_SHARE_AUDIO:
@@ -10882,6 +10939,8 @@ class Call {
10882
10939
  ensureExhausted(trackType, 'Unknown track type');
10883
10940
  break;
10884
10941
  }
10942
+ if (mediaStream)
10943
+ await this.publish(mediaStream, trackType);
10885
10944
  }
10886
10945
  };
10887
10946
  /**
@@ -10896,105 +10955,111 @@ class Call {
10896
10955
  };
10897
10956
  /**
10898
10957
  * Starts publishing the given video stream to the call.
10899
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
10900
- *
10901
- * Consecutive calls to this method will replace the previously published stream.
10902
- * The previous video stream will be stopped.
10903
- *
10904
- * @param videoStream the video stream to publish.
10958
+ * @deprecated use `call.publish()`.
10905
10959
  */
10906
10960
  this.publishVideoStream = async (videoStream) => {
10907
- if (!this.sfuClient)
10908
- throw new Error(`Call not joined yet.`);
10909
- // joining is in progress, and we should wait until the client is ready
10910
- await this.sfuClient.joinTask;
10911
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
10912
- throw new Error('No permission to publish video');
10913
- }
10914
- if (!this.publisher)
10915
- throw new Error('Publisher is not initialized');
10916
- const [videoTrack] = videoStream.getVideoTracks();
10917
- if (!videoTrack)
10918
- throw new Error('There is no video track in the stream');
10919
- if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
10920
- this.trackPublishOrder.push(TrackType.VIDEO);
10921
- }
10922
- await this.publisher.publishStream(videoStream, videoTrack, TrackType.VIDEO, this.publishOptions);
10961
+ await this.publish(videoStream, TrackType.VIDEO);
10923
10962
  };
10924
10963
  /**
10925
10964
  * Starts publishing the given audio stream to the call.
10926
- * The stream will be stopped if the user changes an input device, or if the user leaves the call.
10927
- *
10928
- * Consecutive calls to this method will replace the audio stream that is currently being published.
10929
- * The previous audio stream will be stopped.
10930
- *
10931
- * @param audioStream the audio stream to publish.
10965
+ * @deprecated use `call.publish()`
10932
10966
  */
10933
10967
  this.publishAudioStream = async (audioStream) => {
10934
- if (!this.sfuClient)
10935
- throw new Error(`Call not joined yet.`);
10936
- // joining is in progress, and we should wait until the client is ready
10937
- await this.sfuClient.joinTask;
10938
- if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
10939
- throw new Error('No permission to publish audio');
10940
- }
10941
- if (!this.publisher)
10942
- throw new Error('Publisher is not initialized');
10943
- const [audioTrack] = audioStream.getAudioTracks();
10944
- if (!audioTrack)
10945
- throw new Error('There is no audio track in the stream');
10946
- if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
10947
- this.trackPublishOrder.push(TrackType.AUDIO);
10948
- }
10949
- await this.publisher.publishStream(audioStream, audioTrack, TrackType.AUDIO);
10968
+ await this.publish(audioStream, TrackType.AUDIO);
10950
10969
  };
10951
10970
  /**
10952
10971
  * Starts publishing the given screen-share stream to the call.
10953
- *
10954
- * Consecutive calls to this method will replace the previous screen-share stream.
10955
- * The previous screen-share stream will be stopped.
10956
- *
10957
- * @param screenShareStream the screen-share stream to publish.
10972
+ * @deprecated use `call.publish()`
10958
10973
  */
10959
10974
  this.publishScreenShareStream = async (screenShareStream) => {
10975
+ await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
10976
+ };
10977
+ /**
10978
+ * Publishes the given media stream.
10979
+ *
10980
+ * @param mediaStream the media stream to publish.
10981
+ * @param trackType the type of the track to announce.
10982
+ */
10983
+ this.publish = async (mediaStream, trackType) => {
10960
10984
  if (!this.sfuClient)
10961
10985
  throw new Error(`Call not joined yet.`);
10962
10986
  // joining is in progress, and we should wait until the client is ready
10963
10987
  await this.sfuClient.joinTask;
10964
- if (!this.permissionsContext.hasPermission(OwnCapability.SCREENSHARE)) {
10965
- throw new Error('No permission to publish screen share');
10988
+ if (!this.permissionsContext.canPublish(trackType)) {
10989
+ throw new Error(`No permission to publish ${TrackType[trackType]}`);
10966
10990
  }
10967
10991
  if (!this.publisher)
10968
10992
  throw new Error('Publisher is not initialized');
10969
- const [screenShareTrack] = screenShareStream.getVideoTracks();
10970
- if (!screenShareTrack) {
10971
- throw new Error('There is no screen share track in the stream');
10993
+ const [track] = isAudioTrackType(trackType)
10994
+ ? mediaStream.getAudioTracks()
10995
+ : mediaStream.getVideoTracks();
10996
+ if (!track) {
10997
+ throw new Error(`There is no ${TrackType[trackType]} track in the stream`);
10972
10998
  }
10973
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE)) {
10974
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE);
10975
- }
10976
- const opts = {
10977
- screenShareSettings: this.screenShare.getSettings(),
10978
- };
10979
- await this.publisher.publishStream(screenShareStream, screenShareTrack, TrackType.SCREEN_SHARE, opts);
10980
- const [screenShareAudioTrack] = screenShareStream.getAudioTracks();
10981
- if (screenShareAudioTrack) {
10982
- if (!this.trackPublishOrder.includes(TrackType.SCREEN_SHARE_AUDIO)) {
10983
- this.trackPublishOrder.push(TrackType.SCREEN_SHARE_AUDIO);
10999
+ if (track.readyState === 'ended') {
11000
+ throw new Error(`Can't publish ended tracks.`);
11001
+ }
11002
+ pushToIfMissing(this.trackPublishOrder, trackType);
11003
+ await this.publisher.publish(track, trackType);
11004
+ const trackTypes = [trackType];
11005
+ if (trackType === TrackType.SCREEN_SHARE) {
11006
+ const [audioTrack] = mediaStream.getAudioTracks();
11007
+ if (audioTrack) {
11008
+ pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
11009
+ await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
11010
+ trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
10984
11011
  }
10985
- await this.publisher.publishStream(screenShareStream, screenShareAudioTrack, TrackType.SCREEN_SHARE_AUDIO, opts);
10986
11012
  }
11013
+ await this.updateLocalStreamState(mediaStream, ...trackTypes);
10987
11014
  };
10988
11015
  /**
10989
11016
  * Stops publishing the given track type to the call, if it is currently being published.
10990
- * Underlying track will be stopped and removed from the publisher.
10991
11017
  *
10992
- * @param trackType the track type to stop publishing.
10993
- * @param stopTrack if `true` the track will be stopped, else it will be just disabled
11018
+ * @param trackTypes the track types to stop publishing.
11019
+ */
11020
+ this.stopPublish = async (...trackTypes) => {
11021
+ if (!this.sfuClient || !this.publisher)
11022
+ return;
11023
+ this.publisher.stopTracks(...trackTypes);
11024
+ await this.updateLocalStreamState(undefined, ...trackTypes);
11025
+ };
11026
+ /**
11027
+ * Updates the call state with the new stream.
11028
+ *
11029
+ * @param mediaStream the new stream to update the call state with.
11030
+ * If undefined, the stream will be removed from the call state.
11031
+ * @param trackTypes the track types to update the call state with.
11032
+ */
11033
+ this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
11034
+ if (!this.sfuClient || !this.sfuClient.sessionId)
11035
+ return;
11036
+ await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
11037
+ const { sessionId } = this.sfuClient;
11038
+ for (const trackType of trackTypes) {
11039
+ const streamStateProp = trackTypeToParticipantStreamKey(trackType);
11040
+ if (!streamStateProp)
11041
+ continue;
11042
+ this.state.updateParticipant(sessionId, (p) => ({
11043
+ publishedTracks: mediaStream
11044
+ ? pushToIfMissing([...p.publishedTracks], trackType)
11045
+ : p.publishedTracks.filter((t) => t !== trackType),
11046
+ [streamStateProp]: mediaStream,
11047
+ }));
11048
+ }
11049
+ };
11050
+ /**
11051
+ * Updates the preferred publishing options
11052
+ *
11053
+ * @internal
11054
+ * @param options the options to use.
10994
11055
  */
10995
- this.stopPublish = async (trackType, stopTrack = true) => {
10996
- this.logger('info', `stopPublish ${TrackType[trackType]}, stop tracks: ${stopTrack}`);
10997
- await this.publisher?.unpublishStream(trackType, stopTrack);
11056
+ this.updatePublishOptions = (options) => {
11057
+ this.logger('warn', '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
11058
+ 'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.');
11059
+ if (this.state.callingState === CallingState.JOINED) {
11060
+ this.logger('warn', 'Updating publish options after joining the call does not have an effect');
11061
+ }
11062
+ this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
10998
11063
  };
10999
11064
  /**
11000
11065
  * Notifies the SFU that a noise cancellation process has started.
@@ -11016,6 +11081,15 @@ class Call {
11016
11081
  this.logger('warn', 'Failed to notify stop of noise cancellation', err);
11017
11082
  });
11018
11083
  };
11084
+ /**
11085
+ * Notifies the SFU about the mute state of the given track types.
11086
+ * @internal
11087
+ */
11088
+ this.notifyTrackMuteState = async (muted, ...trackTypes) => {
11089
+ if (!this.sfuClient)
11090
+ return;
11091
+ await this.sfuClient.updateMuteStates(trackTypes.map((trackType) => ({ trackType, muted })));
11092
+ };
11019
11093
  /**
11020
11094
  * Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
11021
11095
  * This is usually helpful when detailed stats for a specific participant are needed.
@@ -11160,7 +11234,43 @@ class Call {
11160
11234
  return this.streamClient.post(`${this.streamClientBasePath}/stop_transcription`);
11161
11235
  };
11162
11236
  /**
11163
- * 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).
11237
+ * Starts the closed captions of the call.
11238
+ */
11239
+ this.startClosedCaptions = async (options) => {
11240
+ const trx = this.state.setCaptioning(true); // optimistic update
11241
+ try {
11242
+ return await this.streamClient.post(`${this.streamClientBasePath}/start_closed_captions`, options);
11243
+ }
11244
+ catch (err) {
11245
+ trx.rollback(); // revert the optimistic update
11246
+ throw err;
11247
+ }
11248
+ };
11249
+ /**
11250
+ * Stops the closed captions of the call.
11251
+ */
11252
+ this.stopClosedCaptions = async (options) => {
11253
+ const trx = this.state.setCaptioning(false); // optimistic update
11254
+ try {
11255
+ return await this.streamClient.post(`${this.streamClientBasePath}/stop_closed_captions`, options);
11256
+ }
11257
+ catch (err) {
11258
+ trx.rollback(); // revert the optimistic update
11259
+ throw err;
11260
+ }
11261
+ };
11262
+ /**
11263
+ * Updates the closed caption settings.
11264
+ *
11265
+ * @param config the closed caption settings to apply
11266
+ */
11267
+ this.updateClosedCaptionSettings = (config) => {
11268
+ this.state.updateClosedCaptionSettings(config);
11269
+ };
11270
+ /**
11271
+ * Sends a `call.permission_request` event to all users connected to the call.
11272
+ * The call settings object contains information about which permissions can be requested during a call
11273
+ * (for example, a user might be allowed to request permission to publish audio, but not video).
11164
11274
  */
11165
11275
  this.requestPermissions = async (data) => {
11166
11276
  const { permissions } = data;
@@ -11443,70 +11553,14 @@ class Call {
11443
11553
  *
11444
11554
  * @internal
11445
11555
  */
11446
- this.applyDeviceConfig = async (status) => {
11447
- await this.initCamera({ setStatus: status }).catch((err) => {
11556
+ this.applyDeviceConfig = async (settings, publish) => {
11557
+ await this.camera.apply(settings.video, publish).catch((err) => {
11448
11558
  this.logger('warn', 'Camera init failed', err);
11449
11559
  });
11450
- await this.initMic({ setStatus: status }).catch((err) => {
11560
+ await this.microphone.apply(settings.audio, publish).catch((err) => {
11451
11561
  this.logger('warn', 'Mic init failed', err);
11452
11562
  });
11453
11563
  };
11454
- this.initCamera = async (options) => {
11455
- // Wait for any in progress camera operation
11456
- await this.camera.statusChangeSettled();
11457
- if (this.state.localParticipant?.videoStream ||
11458
- !this.permissionsContext.hasPermission('send-video')) {
11459
- return;
11460
- }
11461
- // Set camera direction if it's not yet set
11462
- if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
11463
- let defaultDirection = 'front';
11464
- const backendSetting = this.state.settings?.video.camera_facing;
11465
- if (backendSetting) {
11466
- defaultDirection = backendSetting === 'front' ? 'front' : 'back';
11467
- }
11468
- this.camera.state.setDirection(defaultDirection);
11469
- }
11470
- // Set target resolution
11471
- const targetResolution = this.state.settings?.video.target_resolution;
11472
- if (targetResolution) {
11473
- await this.camera.selectTargetResolution(targetResolution);
11474
- }
11475
- if (options.setStatus) {
11476
- // Publish already that was set before we joined
11477
- if (this.camera.enabled &&
11478
- this.camera.state.mediaStream &&
11479
- !this.publisher?.isPublishing(TrackType.VIDEO)) {
11480
- await this.publishVideoStream(this.camera.state.mediaStream);
11481
- }
11482
- // Start camera if backend config specifies, and there is no local setting
11483
- if (this.camera.state.status === undefined &&
11484
- this.state.settings?.video.camera_default_on) {
11485
- await this.camera.enable();
11486
- }
11487
- }
11488
- };
11489
- this.initMic = async (options) => {
11490
- // Wait for any in progress mic operation
11491
- await this.microphone.statusChangeSettled();
11492
- if (this.state.localParticipant?.audioStream ||
11493
- !this.permissionsContext.hasPermission('send-audio')) {
11494
- return;
11495
- }
11496
- if (options.setStatus) {
11497
- // Publish media stream that was set before we joined
11498
- if (this.microphone.enabled &&
11499
- this.microphone.state.mediaStream &&
11500
- !this.publisher?.isPublishing(TrackType.AUDIO)) {
11501
- await this.publishAudioStream(this.microphone.state.mediaStream);
11502
- }
11503
- // Start mic if backend config specifies, and there is no local setting
11504
- if (this.microphone.state.status === undefined &&
11505
- this.state.settings?.audio.mic_default_on) {
11506
- await this.microphone.enable();
11507
- }
11508
- }
11509
- };
11510
11564
  /**
11511
11565
  * Will begin tracking the given element for visibility changes within the
11512
11566
  * configured viewport element (`call.setViewport`).
@@ -11655,109 +11709,6 @@ class Call {
11655
11709
  this.screenShare = new ScreenShareManager(this);
11656
11710
  this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
11657
11711
  }
11658
- async setup() {
11659
- await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
11660
- if (this.initialized)
11661
- return;
11662
- this.leaveCallHooks.add(this.on('all', (event) => {
11663
- // update state with the latest event data
11664
- this.state.updateFromEvent(event);
11665
- }));
11666
- this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
11667
- this.registerEffects();
11668
- this.registerReconnectHandlers();
11669
- if (this.state.callingState === CallingState.LEFT) {
11670
- this.state.setCallingState(CallingState.IDLE);
11671
- }
11672
- this.initialized = true;
11673
- });
11674
- }
11675
- registerEffects() {
11676
- this.leaveCallHooks.add(
11677
- // handles updating the permissions context when the settings change.
11678
- createSubscription(this.state.settings$, (settings) => {
11679
- if (!settings)
11680
- return;
11681
- this.permissionsContext.setCallSettings(settings);
11682
- }));
11683
- this.leaveCallHooks.add(
11684
- // handle the case when the user permissions are modified.
11685
- createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
11686
- this.leaveCallHooks.add(
11687
- // handles the case when the user is blocked by the call owner.
11688
- createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
11689
- if (!blockedUserIds || blockedUserIds.length === 0)
11690
- return;
11691
- const currentUserId = this.currentUserId;
11692
- if (currentUserId && blockedUserIds.includes(currentUserId)) {
11693
- this.logger('info', 'Leaving call because of being blocked');
11694
- await this.leave({ reason: 'user blocked' }).catch((err) => {
11695
- this.logger('error', 'Error leaving call after being blocked', err);
11696
- });
11697
- }
11698
- }));
11699
- this.leaveCallHooks.add(
11700
- // cancel auto-drop when call is
11701
- createSubscription(this.state.session$, (session) => {
11702
- if (!this.ringing)
11703
- return;
11704
- const receiverId = this.clientStore.connectedUser?.id;
11705
- if (!receiverId)
11706
- return;
11707
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
11708
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
11709
- if (isAcceptedByMe || isRejectedByMe) {
11710
- this.cancelAutoDrop();
11711
- }
11712
- }));
11713
- this.leaveCallHooks.add(
11714
- // "ringing" mode effects and event handlers
11715
- createSubscription(this.ringingSubject, (isRinging) => {
11716
- if (!isRinging)
11717
- return;
11718
- const callSession = this.state.session;
11719
- const receiver_id = this.clientStore.connectedUser?.id;
11720
- const ended_at = callSession?.ended_at;
11721
- const created_by_id = this.state.createdBy?.id;
11722
- const rejected_by = callSession?.rejected_by;
11723
- const accepted_by = callSession?.accepted_by;
11724
- let leaveCallIdle = false;
11725
- if (ended_at) {
11726
- // call was ended before it was accepted or rejected so we should leave it to idle
11727
- leaveCallIdle = true;
11728
- }
11729
- else if (created_by_id && rejected_by) {
11730
- if (rejected_by[created_by_id]) {
11731
- // call was cancelled by the caller
11732
- leaveCallIdle = true;
11733
- }
11734
- }
11735
- else if (receiver_id && rejected_by) {
11736
- if (rejected_by[receiver_id]) {
11737
- // call was rejected by the receiver in some other device
11738
- leaveCallIdle = true;
11739
- }
11740
- }
11741
- else if (receiver_id && accepted_by) {
11742
- if (accepted_by[receiver_id]) {
11743
- // call was accepted by the receiver in some other device
11744
- leaveCallIdle = true;
11745
- }
11746
- }
11747
- if (leaveCallIdle) {
11748
- if (this.state.callingState !== CallingState.IDLE) {
11749
- this.state.setCallingState(CallingState.IDLE);
11750
- }
11751
- }
11752
- else {
11753
- if (this.state.callingState === CallingState.IDLE) {
11754
- this.state.setCallingState(CallingState.RINGING);
11755
- }
11756
- this.scheduleAutoDrop();
11757
- this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
11758
- }
11759
- }));
11760
- }
11761
11712
  /**
11762
11713
  * A flag indicating whether the call is "ringing" type of call.
11763
11714
  */
@@ -11776,15 +11727,6 @@ class Call {
11776
11727
  get isCreatedByMe() {
11777
11728
  return this.state.createdBy?.id === this.currentUserId;
11778
11729
  }
11779
- /**
11780
- * Updates the preferred publishing options
11781
- *
11782
- * @internal
11783
- * @param options the options to use.
11784
- */
11785
- updatePublishOptions(options) {
11786
- this.publishOptions = { ...this.publishOptions, ...options };
11787
- }
11788
11730
  }
11789
11731
 
11790
11732
  var https = null;
@@ -12894,7 +12836,7 @@ class StreamClient {
12894
12836
  return await this.wsConnection.connect(this.defaultWSTimeout);
12895
12837
  };
12896
12838
  this.getUserAgent = () => {
12897
- const version = "1.13.1";
12839
+ const version = "1.15.0";
12898
12840
  return (this.userAgent ||
12899
12841
  `stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
12900
12842
  };
@@ -13194,7 +13136,7 @@ class StreamVideoClient {
13194
13136
  clientStore: this.writeableStateStore,
13195
13137
  });
13196
13138
  call.state.updateFromCallResponse(c.call);
13197
- await call.applyDeviceConfig(false);
13139
+ await call.applyDeviceConfig(c.call.settings, false);
13198
13140
  if (data.watch) {
13199
13141
  this.writeableStateStore.registerCall(call);
13200
13142
  }
@@ -13403,5 +13345,5 @@ class StreamVideoClient {
13403
13345
  }
13404
13346
  StreamVideoClient._instanceMap = new Map();
13405
13347
 
13406
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, BlockListOptionsBehaviorEnum, browsers as Browsers, Call, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, ChannelConfigWithInfoAutomodBehaviorEnum, ChannelConfigWithInfoAutomodEnum, ChannelConfigWithInfoBlocklistBehaviorEnum, CreateDeviceRequestPushProviderEnum, DebounceType, DynascaleManager, ErrorFromResponse, InputMediaDeviceManager, InputMediaDeviceManagerState, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, models as SfuModels, SpeakerManager, SpeakerState, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceInfo, getDeviceState, getLogLevel, getLogger, getOSInfo, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio, hasScreenShare, hasScreenShareAudio, hasVideo, isPinned, livestreamOrAudioRoomSortPreset, logLevels, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, role, screenSharing, setDeviceInfo, setLogLevel, setLogger, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking };
13348
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, BlockListOptionsBehaviorEnum, browsers as Browsers, Call, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, ChannelConfigWithInfoAutomodBehaviorEnum, ChannelConfigWithInfoAutomodEnum, ChannelConfigWithInfoBlocklistBehaviorEnum, ChannelOwnCapability, CreateDeviceRequestPushProviderEnum, DebounceType, DynascaleManager, ErrorFromResponse, InputMediaDeviceManager, InputMediaDeviceManagerState, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, models as SfuModels, SpeakerManager, SpeakerState, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceInfo, getDeviceState, getLogLevel, getLogger, getOSInfo, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio, hasScreenShare, hasScreenShareAudio, hasVideo, isPinned, livestreamOrAudioRoomSortPreset, logLevels, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, role, screenSharing, setDeviceInfo, setLogLevel, setLogger, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking };
13407
13349
  //# sourceMappingURL=index.browser.es.js.map