@stream-io/video-client 1.14.0 → 1.15.1
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.
- package/CHANGELOG.md +14 -0
- package/dist/index.browser.es.js +1533 -1783
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1514 -1783
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1533 -1783
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -28
- package/dist/src/StreamSfuClient.d.ts +4 -5
- package/dist/src/devices/CameraManager.d.ts +5 -8
- package/dist/src/devices/InputMediaDeviceManager.d.ts +5 -5
- package/dist/src/devices/MicrophoneManager.d.ts +7 -2
- package/dist/src/devices/ScreenShareManager.d.ts +1 -2
- package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
- package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
- package/dist/src/helpers/array.d.ts +7 -0
- package/dist/src/permissions/PermissionsContext.d.ts +6 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
- package/dist/src/rtc/Dispatcher.d.ts +0 -1
- package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
- package/dist/src/rtc/Publisher.d.ts +32 -86
- package/dist/src/rtc/Subscriber.d.ts +4 -56
- package/dist/src/rtc/TransceiverCache.d.ts +55 -0
- package/dist/src/rtc/codecs.d.ts +1 -15
- package/dist/src/rtc/helpers/sdp.d.ts +8 -0
- package/dist/src/rtc/helpers/tracks.d.ts +1 -0
- package/dist/src/rtc/index.d.ts +3 -0
- package/dist/src/rtc/videoLayers.d.ts +11 -25
- package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
- package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
- package/dist/src/stats/index.d.ts +1 -1
- package/dist/src/stats/types.d.ts +8 -0
- package/dist/src/types.d.ts +12 -22
- package/package.json +1 -1
- package/src/Call.ts +254 -268
- package/src/StreamSfuClient.ts +9 -14
- package/src/StreamVideoClient.ts +1 -1
- package/src/__tests__/Call.publishing.test.ts +306 -0
- package/src/devices/CameraManager.ts +33 -16
- package/src/devices/InputMediaDeviceManager.ts +38 -27
- package/src/devices/MicrophoneManager.ts +29 -8
- package/src/devices/ScreenShareManager.ts +6 -8
- package/src/devices/__tests__/CameraManager.test.ts +111 -14
- package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
- package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
- package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/events/__tests__/internal.test.ts +132 -0
- package/src/events/__tests__/mutes.test.ts +0 -3
- package/src/events/__tests__/speaker.test.ts +92 -0
- package/src/events/participant.ts +3 -4
- package/src/gen/video/sfu/event/events.ts +91 -30
- package/src/gen/video/sfu/models/models.ts +105 -13
- package/src/helpers/array.ts +14 -0
- package/src/permissions/PermissionsContext.ts +22 -0
- package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
- package/src/rpc/__tests__/createClient.test.ts +38 -0
- package/src/rpc/createClient.ts +11 -5
- package/src/rtc/BasePeerConnection.ts +240 -0
- package/src/rtc/Dispatcher.ts +0 -9
- package/src/rtc/IceTrickleBuffer.ts +24 -4
- package/src/rtc/Publisher.ts +210 -528
- package/src/rtc/Subscriber.ts +26 -200
- package/src/rtc/TransceiverCache.ts +120 -0
- package/src/rtc/__tests__/Publisher.test.ts +407 -210
- package/src/rtc/__tests__/Subscriber.test.ts +88 -36
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
- package/src/rtc/__tests__/videoLayers.test.ts +161 -54
- package/src/rtc/codecs.ts +1 -131
- package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
- package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
- package/src/rtc/helpers/sdp.ts +30 -0
- package/src/rtc/helpers/tracks.ts +3 -0
- package/src/rtc/index.ts +4 -0
- package/src/rtc/videoLayers.ts +68 -76
- package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
- package/src/stats/SfuStatsReporter.ts +31 -3
- package/src/stats/index.ts +1 -1
- package/src/stats/types.ts +12 -0
- package/src/types.ts +12 -22
- package/dist/src/helpers/sdp-munging.d.ts +0 -24
- package/dist/src/rtc/bitrateLookup.d.ts +0 -2
- package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
- package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
- package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
- package/src/helpers/sdp-munging.ts +0 -265
- package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
- package/src/rtc/__tests__/codecs.test.ts +0 -145
- package/src/rtc/bitrateLookup.ts +0 -61
- package/src/rtc/helpers/iceCandidate.ts +0 -16
- /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
- /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
package/dist/index.browser.es.js
CHANGED
|
@@ -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
|
|
8
|
+
import { parse } from 'sdp-transform';
|
|
9
|
+
import { UAParser } from 'ua-parser-js';
|
|
10
10
|
|
|
11
11
|
/* tslint:disable */
|
|
12
12
|
/* eslint-disable */
|
|
@@ -1224,23 +1224,33 @@ class VideoLayer$Type extends MessageType {
|
|
|
1224
1224
|
*/
|
|
1225
1225
|
const VideoLayer = new VideoLayer$Type();
|
|
1226
1226
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1227
|
-
class
|
|
1227
|
+
class SubscribeOption$Type extends MessageType {
|
|
1228
1228
|
constructor() {
|
|
1229
|
-
super('stream.video.sfu.models.
|
|
1229
|
+
super('stream.video.sfu.models.SubscribeOption', [
|
|
1230
1230
|
{
|
|
1231
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,
|
|
1232
1242
|
name: 'codecs',
|
|
1233
1243
|
kind: 'message',
|
|
1234
1244
|
repeat: 1 /*RepeatType.PACKED*/,
|
|
1235
|
-
T: () =>
|
|
1245
|
+
T: () => Codec,
|
|
1236
1246
|
},
|
|
1237
1247
|
]);
|
|
1238
1248
|
}
|
|
1239
1249
|
}
|
|
1240
1250
|
/**
|
|
1241
|
-
* @generated MessageType for protobuf message stream.video.sfu.models.
|
|
1251
|
+
* @generated MessageType for protobuf message stream.video.sfu.models.SubscribeOption
|
|
1242
1252
|
*/
|
|
1243
|
-
const
|
|
1253
|
+
const SubscribeOption = new SubscribeOption$Type();
|
|
1244
1254
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1245
1255
|
class PublishOption$Type extends MessageType {
|
|
1246
1256
|
constructor() {
|
|
@@ -1270,6 +1280,13 @@ class PublishOption$Type extends MessageType {
|
|
|
1270
1280
|
kind: 'scalar',
|
|
1271
1281
|
T: 5 /*ScalarType.INT32*/,
|
|
1272
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*/ },
|
|
1273
1290
|
]);
|
|
1274
1291
|
}
|
|
1275
1292
|
}
|
|
@@ -1282,7 +1299,7 @@ class Codec$Type extends MessageType {
|
|
|
1282
1299
|
constructor() {
|
|
1283
1300
|
super('stream.video.sfu.models.Codec', [
|
|
1284
1301
|
{
|
|
1285
|
-
no:
|
|
1302
|
+
no: 16,
|
|
1286
1303
|
name: 'payload_type',
|
|
1287
1304
|
kind: 'scalar',
|
|
1288
1305
|
T: 13 /*ScalarType.UINT32*/,
|
|
@@ -1295,7 +1312,7 @@ class Codec$Type extends MessageType {
|
|
|
1295
1312
|
T: 13 /*ScalarType.UINT32*/,
|
|
1296
1313
|
},
|
|
1297
1314
|
{
|
|
1298
|
-
no:
|
|
1315
|
+
no: 15,
|
|
1299
1316
|
name: 'encoding_parameters',
|
|
1300
1317
|
kind: 'scalar',
|
|
1301
1318
|
T: 9 /*ScalarType.STRING*/,
|
|
@@ -1359,6 +1376,13 @@ class TrackInfo$Type extends MessageType {
|
|
|
1359
1376
|
{ no: 8, name: 'stereo', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
|
|
1360
1377
|
{ no: 9, name: 'red', kind: 'scalar', T: 8 /*ScalarType.BOOL*/ },
|
|
1361
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
|
+
},
|
|
1362
1386
|
]);
|
|
1363
1387
|
}
|
|
1364
1388
|
}
|
|
@@ -1632,10 +1656,10 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
1632
1656
|
get PeerType () { return PeerType; },
|
|
1633
1657
|
Pin: Pin,
|
|
1634
1658
|
PublishOption: PublishOption,
|
|
1635
|
-
PublishOptions: PublishOptions,
|
|
1636
1659
|
Sdk: Sdk,
|
|
1637
1660
|
get SdkType () { return SdkType; },
|
|
1638
1661
|
StreamQuality: StreamQuality,
|
|
1662
|
+
SubscribeOption: SubscribeOption,
|
|
1639
1663
|
TrackInfo: TrackInfo,
|
|
1640
1664
|
get TrackType () { return TrackType; },
|
|
1641
1665
|
get TrackUnpublishReason () { return TrackUnpublishReason; },
|
|
@@ -2265,13 +2289,6 @@ class SfuEvent$Type extends MessageType {
|
|
|
2265
2289
|
oneof: 'eventPayload',
|
|
2266
2290
|
T: () => ParticipantMigrationComplete,
|
|
2267
2291
|
},
|
|
2268
|
-
{
|
|
2269
|
-
no: 26,
|
|
2270
|
-
name: 'codec_negotiation_complete',
|
|
2271
|
-
kind: 'message',
|
|
2272
|
-
oneof: 'eventPayload',
|
|
2273
|
-
T: () => CodecNegotiationComplete,
|
|
2274
|
-
},
|
|
2275
2292
|
{
|
|
2276
2293
|
no: 27,
|
|
2277
2294
|
name: 'change_publish_options',
|
|
@@ -2292,10 +2309,12 @@ class ChangePublishOptions$Type extends MessageType {
|
|
|
2292
2309
|
super('stream.video.sfu.event.ChangePublishOptions', [
|
|
2293
2310
|
{
|
|
2294
2311
|
no: 1,
|
|
2295
|
-
name: '
|
|
2312
|
+
name: 'publish_options',
|
|
2296
2313
|
kind: 'message',
|
|
2314
|
+
repeat: 1 /*RepeatType.PACKED*/,
|
|
2297
2315
|
T: () => PublishOption,
|
|
2298
2316
|
},
|
|
2317
|
+
{ no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
2299
2318
|
]);
|
|
2300
2319
|
}
|
|
2301
2320
|
}
|
|
@@ -2304,15 +2323,15 @@ class ChangePublishOptions$Type extends MessageType {
|
|
|
2304
2323
|
*/
|
|
2305
2324
|
const ChangePublishOptions = new ChangePublishOptions$Type();
|
|
2306
2325
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
2307
|
-
class
|
|
2326
|
+
class ChangePublishOptionsComplete$Type extends MessageType {
|
|
2308
2327
|
constructor() {
|
|
2309
|
-
super('stream.video.sfu.event.
|
|
2328
|
+
super('stream.video.sfu.event.ChangePublishOptionsComplete', []);
|
|
2310
2329
|
}
|
|
2311
2330
|
}
|
|
2312
2331
|
/**
|
|
2313
|
-
* @generated MessageType for protobuf message stream.video.sfu.event.
|
|
2332
|
+
* @generated MessageType for protobuf message stream.video.sfu.event.ChangePublishOptionsComplete
|
|
2314
2333
|
*/
|
|
2315
|
-
const
|
|
2334
|
+
const ChangePublishOptionsComplete = new ChangePublishOptionsComplete$Type();
|
|
2316
2335
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
2317
2336
|
class ParticipantMigrationComplete$Type extends MessageType {
|
|
2318
2337
|
constructor() {
|
|
@@ -2570,6 +2589,20 @@ class JoinRequest$Type extends MessageType {
|
|
|
2570
2589
|
kind: 'message',
|
|
2571
2590
|
T: () => ReconnectDetails,
|
|
2572
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
|
+
},
|
|
2573
2606
|
]);
|
|
2574
2607
|
}
|
|
2575
2608
|
}
|
|
@@ -2677,7 +2710,8 @@ class JoinResponse$Type extends MessageType {
|
|
|
2677
2710
|
no: 4,
|
|
2678
2711
|
name: 'publish_options',
|
|
2679
2712
|
kind: 'message',
|
|
2680
|
-
|
|
2713
|
+
repeat: 1 /*RepeatType.PACKED*/,
|
|
2714
|
+
T: () => PublishOption,
|
|
2681
2715
|
},
|
|
2682
2716
|
]);
|
|
2683
2717
|
}
|
|
@@ -2842,6 +2876,22 @@ class AudioSender$Type extends MessageType {
|
|
|
2842
2876
|
constructor() {
|
|
2843
2877
|
super('stream.video.sfu.event.AudioSender', [
|
|
2844
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
|
+
},
|
|
2845
2895
|
]);
|
|
2846
2896
|
}
|
|
2847
2897
|
}
|
|
@@ -2894,6 +2944,22 @@ class VideoSender$Type extends MessageType {
|
|
|
2894
2944
|
repeat: 1 /*RepeatType.PACKED*/,
|
|
2895
2945
|
T: () => VideoLayerSetting,
|
|
2896
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
|
+
},
|
|
2897
2963
|
]);
|
|
2898
2964
|
}
|
|
2899
2965
|
}
|
|
@@ -2990,8 +3056,8 @@ var events = /*#__PURE__*/Object.freeze({
|
|
|
2990
3056
|
CallEnded: CallEnded,
|
|
2991
3057
|
CallGrantsUpdated: CallGrantsUpdated,
|
|
2992
3058
|
ChangePublishOptions: ChangePublishOptions,
|
|
3059
|
+
ChangePublishOptionsComplete: ChangePublishOptionsComplete,
|
|
2993
3060
|
ChangePublishQuality: ChangePublishQuality,
|
|
2994
|
-
CodecNegotiationComplete: CodecNegotiationComplete,
|
|
2995
3061
|
ConnectionQualityChanged: ConnectionQualityChanged,
|
|
2996
3062
|
ConnectionQualityInfo: ConnectionQualityInfo,
|
|
2997
3063
|
DominantSpeakerChanged: DominantSpeakerChanged,
|
|
@@ -3138,11 +3204,18 @@ const withHeaders = (headers) => {
|
|
|
3138
3204
|
const withRequestLogger = (logger, level) => {
|
|
3139
3205
|
return {
|
|
3140
3206
|
interceptUnary: (next, method, input, options) => {
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
options
|
|
3144
|
-
}
|
|
3145
|
-
|
|
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;
|
|
3146
3219
|
},
|
|
3147
3220
|
};
|
|
3148
3221
|
};
|
|
@@ -3359,665 +3432,139 @@ const retryable = async (rpc, signal) => {
|
|
|
3359
3432
|
return result;
|
|
3360
3433
|
};
|
|
3361
3434
|
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
const
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
const setOSInfo = (info) => {
|
|
3381
|
-
osInfo = info;
|
|
3382
|
-
};
|
|
3383
|
-
const getOSInfo = () => {
|
|
3384
|
-
return osInfo;
|
|
3385
|
-
};
|
|
3386
|
-
const setDeviceInfo = (info) => {
|
|
3387
|
-
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;
|
|
3388
3453
|
};
|
|
3389
|
-
|
|
3390
|
-
|
|
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');
|
|
3391
3467
|
};
|
|
3392
|
-
|
|
3393
|
-
|
|
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,
|
|
3394
3492
|
};
|
|
3395
|
-
const
|
|
3396
|
-
|
|
3493
|
+
const isSfuEvent = (eventName) => {
|
|
3494
|
+
return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
|
|
3397
3495
|
};
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
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
|
+
}
|
|
3517
|
+
};
|
|
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);
|
|
3413
3527
|
};
|
|
3414
3528
|
}
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
/**
|
|
3532
|
+
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
3533
|
+
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
3534
|
+
*/
|
|
3535
|
+
class IceTrickleBuffer {
|
|
3536
|
+
constructor() {
|
|
3537
|
+
this.subscriberCandidates = new ReplaySubject();
|
|
3538
|
+
this.publisherCandidates = new ReplaySubject();
|
|
3539
|
+
this.push = (iceTrickle) => {
|
|
3540
|
+
const iceCandidate = toIceCandidate(iceTrickle);
|
|
3541
|
+
if (!iceCandidate)
|
|
3542
|
+
return;
|
|
3543
|
+
if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
|
|
3544
|
+
this.subscriberCandidates.next(iceCandidate);
|
|
3545
|
+
}
|
|
3546
|
+
else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
|
|
3547
|
+
this.publisherCandidates.next(iceCandidate);
|
|
3548
|
+
}
|
|
3549
|
+
else {
|
|
3550
|
+
const logger = getLogger(['sfu-client']);
|
|
3551
|
+
logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
|
|
3552
|
+
}
|
|
3553
|
+
};
|
|
3554
|
+
this.dispose = () => {
|
|
3555
|
+
this.subscriberCandidates.complete();
|
|
3556
|
+
this.publisherCandidates.complete();
|
|
3425
3557
|
};
|
|
3426
3558
|
}
|
|
3427
|
-
}
|
|
3428
|
-
const
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
return;
|
|
3559
|
+
}
|
|
3560
|
+
const toIceCandidate = (iceTrickle) => {
|
|
3561
|
+
try {
|
|
3562
|
+
return JSON.parse(iceTrickle.iceCandidate);
|
|
3432
3563
|
}
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
thermalState: deviceState?.oneofKind === 'android'
|
|
3438
|
-
? deviceState.android.thermalState
|
|
3439
|
-
: AndroidThermalState.UNSPECIFIED,
|
|
3440
|
-
isPowerSaverMode: powerMode,
|
|
3441
|
-
},
|
|
3442
|
-
};
|
|
3443
|
-
}
|
|
3444
|
-
if (osInfo.name.toLowerCase() === 'ios') {
|
|
3445
|
-
deviceState = {
|
|
3446
|
-
oneofKind: 'apple',
|
|
3447
|
-
apple: {
|
|
3448
|
-
thermalState: deviceState?.oneofKind === 'apple'
|
|
3449
|
-
? deviceState.apple.thermalState
|
|
3450
|
-
: AppleThermalState.UNSPECIFIED,
|
|
3451
|
-
isLowPowerModeEnabled: powerMode,
|
|
3452
|
-
},
|
|
3453
|
-
};
|
|
3454
|
-
}
|
|
3455
|
-
};
|
|
3456
|
-
const getDeviceState = () => {
|
|
3457
|
-
return deviceState;
|
|
3458
|
-
};
|
|
3459
|
-
const getClientDetails = () => {
|
|
3460
|
-
if (isReactNative()) {
|
|
3461
|
-
// Since RN doesn't support web, sharing browser info is not required
|
|
3462
|
-
return {
|
|
3463
|
-
sdk: getSdkInfo(),
|
|
3464
|
-
os: getOSInfo(),
|
|
3465
|
-
device: getDeviceInfo(),
|
|
3466
|
-
};
|
|
3467
|
-
}
|
|
3468
|
-
const userAgent = new UAParser(navigator.userAgent);
|
|
3469
|
-
const { browser, os, device, cpu } = userAgent.getResult();
|
|
3470
|
-
return {
|
|
3471
|
-
sdk: getSdkInfo(),
|
|
3472
|
-
browser: {
|
|
3473
|
-
name: browser.name || navigator.userAgent,
|
|
3474
|
-
version: browser.version || '',
|
|
3475
|
-
},
|
|
3476
|
-
os: {
|
|
3477
|
-
name: os.name || '',
|
|
3478
|
-
version: os.version || '',
|
|
3479
|
-
architecture: cpu.architecture || '',
|
|
3480
|
-
},
|
|
3481
|
-
device: {
|
|
3482
|
-
name: [device.vendor, device.model, device.type]
|
|
3483
|
-
.filter(Boolean)
|
|
3484
|
-
.join(' '),
|
|
3485
|
-
version: '',
|
|
3486
|
-
},
|
|
3487
|
-
};
|
|
3488
|
-
};
|
|
3489
|
-
|
|
3490
|
-
/**
|
|
3491
|
-
* Checks whether the current browser is Safari.
|
|
3492
|
-
*/
|
|
3493
|
-
const isSafari = () => {
|
|
3494
|
-
if (typeof navigator === 'undefined')
|
|
3495
|
-
return false;
|
|
3496
|
-
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
3497
|
-
};
|
|
3498
|
-
/**
|
|
3499
|
-
* Checks whether the current browser is Firefox.
|
|
3500
|
-
*/
|
|
3501
|
-
const isFirefox = () => {
|
|
3502
|
-
if (typeof navigator === 'undefined')
|
|
3503
|
-
return false;
|
|
3504
|
-
return navigator.userAgent?.includes('Firefox');
|
|
3505
|
-
};
|
|
3506
|
-
/**
|
|
3507
|
-
* Checks whether the current browser is Google Chrome.
|
|
3508
|
-
*/
|
|
3509
|
-
const isChrome = () => {
|
|
3510
|
-
if (typeof navigator === 'undefined')
|
|
3511
|
-
return false;
|
|
3512
|
-
return navigator.userAgent?.includes('Chrome');
|
|
3513
|
-
};
|
|
3514
|
-
|
|
3515
|
-
var browsers = /*#__PURE__*/Object.freeze({
|
|
3516
|
-
__proto__: null,
|
|
3517
|
-
isChrome: isChrome,
|
|
3518
|
-
isFirefox: isFirefox,
|
|
3519
|
-
isSafari: isSafari
|
|
3520
|
-
});
|
|
3521
|
-
|
|
3522
|
-
/**
|
|
3523
|
-
* Returns back a list of sorted codecs, with the preferred codec first.
|
|
3524
|
-
*
|
|
3525
|
-
* @param kind the kind of codec to get.
|
|
3526
|
-
* @param preferredCodec the codec to prioritize (vp8, h264, vp9, av1...).
|
|
3527
|
-
* @param codecToRemove the codec to exclude from the list.
|
|
3528
|
-
* @param codecPreferencesSource the source of the codec preferences.
|
|
3529
|
-
*/
|
|
3530
|
-
const getPreferredCodecs = (kind, preferredCodec, codecToRemove, codecPreferencesSource) => {
|
|
3531
|
-
const source = codecPreferencesSource === 'receiver' ? RTCRtpReceiver : RTCRtpSender;
|
|
3532
|
-
if (!('getCapabilities' in source))
|
|
3533
|
-
return;
|
|
3534
|
-
const capabilities = source.getCapabilities(kind);
|
|
3535
|
-
if (!capabilities)
|
|
3536
|
-
return;
|
|
3537
|
-
const preferred = [];
|
|
3538
|
-
const partiallyPreferred = [];
|
|
3539
|
-
const unpreferred = [];
|
|
3540
|
-
const preferredCodecMimeType = `${kind}/${preferredCodec.toLowerCase()}`;
|
|
3541
|
-
const codecToRemoveMimeType = codecToRemove && `${kind}/${codecToRemove.toLowerCase()}`;
|
|
3542
|
-
for (const codec of capabilities.codecs) {
|
|
3543
|
-
const codecMimeType = codec.mimeType.toLowerCase();
|
|
3544
|
-
const shouldRemoveCodec = codecMimeType === codecToRemoveMimeType;
|
|
3545
|
-
if (shouldRemoveCodec)
|
|
3546
|
-
continue; // skip this codec
|
|
3547
|
-
const isPreferredCodec = codecMimeType === preferredCodecMimeType;
|
|
3548
|
-
if (!isPreferredCodec) {
|
|
3549
|
-
unpreferred.push(codec);
|
|
3550
|
-
continue;
|
|
3551
|
-
}
|
|
3552
|
-
// h264 is a special case, we want to prioritize the baseline codec with
|
|
3553
|
-
// profile-level-id is 42e01f and packetization-mode=0 for maximum
|
|
3554
|
-
// cross-browser compatibility.
|
|
3555
|
-
// this branch covers the other cases, such as vp8.
|
|
3556
|
-
if (codecMimeType !== 'video/h264') {
|
|
3557
|
-
preferred.push(codec);
|
|
3558
|
-
continue;
|
|
3559
|
-
}
|
|
3560
|
-
const sdpFmtpLine = codec.sdpFmtpLine;
|
|
3561
|
-
if (!sdpFmtpLine || !sdpFmtpLine.includes('profile-level-id=42')) {
|
|
3562
|
-
// this is not the baseline h264 codec, prioritize it lower
|
|
3563
|
-
partiallyPreferred.push(codec);
|
|
3564
|
-
continue;
|
|
3565
|
-
}
|
|
3566
|
-
if (sdpFmtpLine.includes('packetization-mode=1')) {
|
|
3567
|
-
preferred.unshift(codec);
|
|
3568
|
-
}
|
|
3569
|
-
else {
|
|
3570
|
-
preferred.push(codec);
|
|
3571
|
-
}
|
|
3572
|
-
}
|
|
3573
|
-
// return a sorted list of codecs, with the preferred codecs first
|
|
3574
|
-
return [...preferred, ...partiallyPreferred, ...unpreferred];
|
|
3575
|
-
};
|
|
3576
|
-
/**
|
|
3577
|
-
* Returns a generic SDP for the given direction.
|
|
3578
|
-
* We use this SDP to send it as part of our JoinRequest so that the SFU
|
|
3579
|
-
* can use it to determine client's codec capabilities.
|
|
3580
|
-
*
|
|
3581
|
-
* @param direction the direction of the transceiver.
|
|
3582
|
-
*/
|
|
3583
|
-
const getGenericSdp = async (direction) => {
|
|
3584
|
-
const tempPc = new RTCPeerConnection();
|
|
3585
|
-
tempPc.addTransceiver('video', { direction });
|
|
3586
|
-
tempPc.addTransceiver('audio', { direction });
|
|
3587
|
-
const offer = await tempPc.createOffer();
|
|
3588
|
-
const sdp = offer.sdp ?? '';
|
|
3589
|
-
tempPc.getTransceivers().forEach((t) => {
|
|
3590
|
-
t.stop?.();
|
|
3591
|
-
});
|
|
3592
|
-
tempPc.close();
|
|
3593
|
-
return sdp;
|
|
3594
|
-
};
|
|
3595
|
-
/**
|
|
3596
|
-
* Returns the optimal video codec for the device.
|
|
3597
|
-
*/
|
|
3598
|
-
const getOptimalVideoCodec = (preferredCodec) => {
|
|
3599
|
-
if (isReactNative()) {
|
|
3600
|
-
const os = getOSInfo()?.name.toLowerCase();
|
|
3601
|
-
if (os === 'android')
|
|
3602
|
-
return preferredOr(preferredCodec, 'vp8');
|
|
3603
|
-
if (os === 'ios' || os === 'ipados') {
|
|
3604
|
-
return supportsH264Baseline() ? 'h264' : 'vp8';
|
|
3605
|
-
}
|
|
3606
|
-
return preferredOr(preferredCodec, 'h264');
|
|
3607
|
-
}
|
|
3608
|
-
if (isSafari())
|
|
3609
|
-
return 'h264';
|
|
3610
|
-
if (isFirefox())
|
|
3611
|
-
return 'vp8';
|
|
3612
|
-
return preferredOr(preferredCodec, 'vp8');
|
|
3613
|
-
};
|
|
3614
|
-
/**
|
|
3615
|
-
* Determines if the platform supports the preferred codec.
|
|
3616
|
-
* If not, it returns the fallback codec.
|
|
3617
|
-
*/
|
|
3618
|
-
const preferredOr = (codec, fallback) => {
|
|
3619
|
-
if (!codec)
|
|
3620
|
-
return fallback;
|
|
3621
|
-
if (!('getCapabilities' in RTCRtpSender))
|
|
3622
|
-
return fallback;
|
|
3623
|
-
const capabilities = RTCRtpSender.getCapabilities('video');
|
|
3624
|
-
if (!capabilities)
|
|
3625
|
-
return fallback;
|
|
3626
|
-
// Safari and Firefox do not have a good support encoding to SVC codecs,
|
|
3627
|
-
// so we disable it for them.
|
|
3628
|
-
if (isSvcCodec(codec) && (isSafari() || isFirefox()))
|
|
3629
|
-
return fallback;
|
|
3630
|
-
const { codecs } = capabilities;
|
|
3631
|
-
const codecMimeType = `video/${codec}`.toLowerCase();
|
|
3632
|
-
return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
|
|
3633
|
-
? codec
|
|
3634
|
-
: fallback;
|
|
3635
|
-
};
|
|
3636
|
-
/**
|
|
3637
|
-
* Returns whether the platform supports the H264 baseline codec.
|
|
3638
|
-
*/
|
|
3639
|
-
const supportsH264Baseline = () => {
|
|
3640
|
-
if (!('getCapabilities' in RTCRtpSender))
|
|
3641
|
-
return false;
|
|
3642
|
-
const capabilities = RTCRtpSender.getCapabilities('video');
|
|
3643
|
-
if (!capabilities)
|
|
3644
|
-
return false;
|
|
3645
|
-
return capabilities.codecs.some((c) => c.mimeType.toLowerCase() === 'video/h264' &&
|
|
3646
|
-
c.sdpFmtpLine?.includes('profile-level-id=42e01f'));
|
|
3647
|
-
};
|
|
3648
|
-
/**
|
|
3649
|
-
* Returns whether the codec is an SVC codec.
|
|
3650
|
-
*
|
|
3651
|
-
* @param codecOrMimeType the codec to check.
|
|
3652
|
-
*/
|
|
3653
|
-
const isSvcCodec = (codecOrMimeType) => {
|
|
3654
|
-
if (!codecOrMimeType)
|
|
3655
|
-
return false;
|
|
3656
|
-
codecOrMimeType = codecOrMimeType.toLowerCase();
|
|
3657
|
-
return (codecOrMimeType === 'vp9' ||
|
|
3658
|
-
codecOrMimeType === 'av1' ||
|
|
3659
|
-
codecOrMimeType === 'video/vp9' ||
|
|
3660
|
-
codecOrMimeType === 'video/av1');
|
|
3661
|
-
};
|
|
3662
|
-
|
|
3663
|
-
const sfuEventKinds = {
|
|
3664
|
-
subscriberOffer: undefined,
|
|
3665
|
-
publisherAnswer: undefined,
|
|
3666
|
-
connectionQualityChanged: undefined,
|
|
3667
|
-
audioLevelChanged: undefined,
|
|
3668
|
-
iceTrickle: undefined,
|
|
3669
|
-
changePublishQuality: undefined,
|
|
3670
|
-
participantJoined: undefined,
|
|
3671
|
-
participantLeft: undefined,
|
|
3672
|
-
dominantSpeakerChanged: undefined,
|
|
3673
|
-
joinResponse: undefined,
|
|
3674
|
-
healthCheckResponse: undefined,
|
|
3675
|
-
trackPublished: undefined,
|
|
3676
|
-
trackUnpublished: undefined,
|
|
3677
|
-
error: undefined,
|
|
3678
|
-
callGrantsUpdated: undefined,
|
|
3679
|
-
goAway: undefined,
|
|
3680
|
-
iceRestart: undefined,
|
|
3681
|
-
pinsUpdated: undefined,
|
|
3682
|
-
callEnded: undefined,
|
|
3683
|
-
participantUpdated: undefined,
|
|
3684
|
-
participantMigrationComplete: undefined,
|
|
3685
|
-
codecNegotiationComplete: undefined,
|
|
3686
|
-
changePublishOptions: undefined,
|
|
3687
|
-
};
|
|
3688
|
-
const isSfuEvent = (eventName) => {
|
|
3689
|
-
return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
|
|
3690
|
-
};
|
|
3691
|
-
class Dispatcher {
|
|
3692
|
-
constructor() {
|
|
3693
|
-
this.logger = getLogger(['Dispatcher']);
|
|
3694
|
-
this.subscribers = {};
|
|
3695
|
-
this.dispatch = (message, logTag = '0') => {
|
|
3696
|
-
const eventKind = message.eventPayload.oneofKind;
|
|
3697
|
-
if (!eventKind)
|
|
3698
|
-
return;
|
|
3699
|
-
const payload = message.eventPayload[eventKind];
|
|
3700
|
-
this.logger('debug', `Dispatching ${eventKind}, tag=${logTag}`, payload);
|
|
3701
|
-
const listeners = this.subscribers[eventKind];
|
|
3702
|
-
if (!listeners)
|
|
3703
|
-
return;
|
|
3704
|
-
for (const fn of listeners) {
|
|
3705
|
-
try {
|
|
3706
|
-
fn(payload);
|
|
3707
|
-
}
|
|
3708
|
-
catch (e) {
|
|
3709
|
-
this.logger('warn', 'Listener failed with error', e);
|
|
3710
|
-
}
|
|
3711
|
-
}
|
|
3712
|
-
};
|
|
3713
|
-
this.on = (eventName, fn) => {
|
|
3714
|
-
var _a;
|
|
3715
|
-
((_a = this.subscribers)[eventName] ?? (_a[eventName] = [])).push(fn);
|
|
3716
|
-
return () => {
|
|
3717
|
-
this.off(eventName, fn);
|
|
3718
|
-
};
|
|
3719
|
-
};
|
|
3720
|
-
this.off = (eventName, fn) => {
|
|
3721
|
-
this.subscribers[eventName] = (this.subscribers[eventName] || []).filter((f) => f !== fn);
|
|
3722
|
-
};
|
|
3723
|
-
this.offAll = (eventName) => {
|
|
3724
|
-
if (eventName) {
|
|
3725
|
-
this.subscribers[eventName] = [];
|
|
3726
|
-
}
|
|
3727
|
-
else {
|
|
3728
|
-
this.subscribers = {};
|
|
3729
|
-
}
|
|
3730
|
-
};
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
|
|
3734
|
-
/**
|
|
3735
|
-
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
3736
|
-
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
3737
|
-
*/
|
|
3738
|
-
class IceTrickleBuffer {
|
|
3739
|
-
constructor() {
|
|
3740
|
-
this.subscriberCandidates = new ReplaySubject();
|
|
3741
|
-
this.publisherCandidates = new ReplaySubject();
|
|
3742
|
-
this.push = (iceTrickle) => {
|
|
3743
|
-
if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
|
|
3744
|
-
this.subscriberCandidates.next(iceTrickle);
|
|
3745
|
-
}
|
|
3746
|
-
else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
|
|
3747
|
-
this.publisherCandidates.next(iceTrickle);
|
|
3748
|
-
}
|
|
3749
|
-
else {
|
|
3750
|
-
const logger = getLogger(['sfu-client']);
|
|
3751
|
-
logger('warn', `ICETrickle, Unknown peer type`, iceTrickle);
|
|
3752
|
-
}
|
|
3753
|
-
};
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
|
|
3757
|
-
function getIceCandidate(candidate) {
|
|
3758
|
-
if (!candidate.usernameFragment) {
|
|
3759
|
-
// react-native-webrtc doesn't include usernameFragment in the candidate
|
|
3760
|
-
const splittedCandidate = candidate.candidate.split(' ');
|
|
3761
|
-
const ufragIndex = splittedCandidate.findIndex((s) => s === 'ufrag') + 1;
|
|
3762
|
-
const usernameFragment = splittedCandidate[ufragIndex];
|
|
3763
|
-
return JSON.stringify({ ...candidate, usernameFragment });
|
|
3764
|
-
}
|
|
3765
|
-
else {
|
|
3766
|
-
return JSON.stringify(candidate.toJSON());
|
|
3767
|
-
}
|
|
3768
|
-
}
|
|
3769
|
-
|
|
3770
|
-
const bitrateLookupTable = {
|
|
3771
|
-
h264: {
|
|
3772
|
-
2160: 5000000,
|
|
3773
|
-
1440: 3000000,
|
|
3774
|
-
1080: 2000000,
|
|
3775
|
-
720: 1250000,
|
|
3776
|
-
540: 750000,
|
|
3777
|
-
360: 400000,
|
|
3778
|
-
default: 1250000,
|
|
3779
|
-
},
|
|
3780
|
-
vp8: {
|
|
3781
|
-
2160: 5000000,
|
|
3782
|
-
1440: 2750000,
|
|
3783
|
-
1080: 2000000,
|
|
3784
|
-
720: 1250000,
|
|
3785
|
-
540: 600000,
|
|
3786
|
-
360: 350000,
|
|
3787
|
-
default: 1250000,
|
|
3788
|
-
},
|
|
3789
|
-
vp9: {
|
|
3790
|
-
2160: 3000000,
|
|
3791
|
-
1440: 2000000,
|
|
3792
|
-
1080: 1500000,
|
|
3793
|
-
720: 1250000,
|
|
3794
|
-
540: 500000,
|
|
3795
|
-
360: 275000,
|
|
3796
|
-
default: 1250000,
|
|
3797
|
-
},
|
|
3798
|
-
av1: {
|
|
3799
|
-
2160: 2000000,
|
|
3800
|
-
1440: 1550000,
|
|
3801
|
-
1080: 1000000,
|
|
3802
|
-
720: 600000,
|
|
3803
|
-
540: 350000,
|
|
3804
|
-
360: 200000,
|
|
3805
|
-
default: 600000,
|
|
3806
|
-
},
|
|
3807
|
-
};
|
|
3808
|
-
const getOptimalBitrate = (codec, frameHeight) => {
|
|
3809
|
-
const codecLookup = bitrateLookupTable[codec];
|
|
3810
|
-
if (!codecLookup)
|
|
3811
|
-
throw new Error(`Unknown codec: ${codec}`);
|
|
3812
|
-
let bitrate = codecLookup[frameHeight];
|
|
3813
|
-
if (!bitrate) {
|
|
3814
|
-
const keys = Object.keys(codecLookup).map(Number);
|
|
3815
|
-
const nearest = keys.reduce((a, b) => Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a);
|
|
3816
|
-
bitrate = codecLookup[nearest];
|
|
3817
|
-
}
|
|
3818
|
-
return bitrate ?? codecLookup.default;
|
|
3819
|
-
};
|
|
3820
|
-
|
|
3821
|
-
const DEFAULT_BITRATE = 1250000;
|
|
3822
|
-
const defaultTargetResolution = {
|
|
3823
|
-
bitrate: DEFAULT_BITRATE,
|
|
3824
|
-
width: 1280,
|
|
3825
|
-
height: 720,
|
|
3826
|
-
};
|
|
3827
|
-
const defaultBitratePerRid = {
|
|
3828
|
-
q: 300000,
|
|
3829
|
-
h: 750000,
|
|
3830
|
-
f: DEFAULT_BITRATE,
|
|
3831
|
-
};
|
|
3832
|
-
/**
|
|
3833
|
-
* In SVC, we need to send only one video encoding (layer).
|
|
3834
|
-
* this layer will have the additional spatial and temporal layers
|
|
3835
|
-
* defined via the scalabilityMode property.
|
|
3836
|
-
*
|
|
3837
|
-
* @param layers the layers to process.
|
|
3838
|
-
*/
|
|
3839
|
-
const toSvcEncodings = (layers) => {
|
|
3840
|
-
// we take the `f` layer, and we rename it to `q`.
|
|
3841
|
-
return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
|
|
3842
|
-
};
|
|
3843
|
-
/**
|
|
3844
|
-
* Converts the rid to a video quality.
|
|
3845
|
-
*/
|
|
3846
|
-
const ridToVideoQuality = (rid) => {
|
|
3847
|
-
return rid === 'q'
|
|
3848
|
-
? VideoQuality.LOW_UNSPECIFIED
|
|
3849
|
-
: rid === 'h'
|
|
3850
|
-
? VideoQuality.MID
|
|
3851
|
-
: VideoQuality.HIGH; // default to HIGH
|
|
3852
|
-
};
|
|
3853
|
-
/**
|
|
3854
|
-
* Determines the most optimal video layers for simulcasting
|
|
3855
|
-
* for the given track.
|
|
3856
|
-
*
|
|
3857
|
-
* @param videoTrack the video track to find optimal layers for.
|
|
3858
|
-
* @param targetResolution the expected target resolution.
|
|
3859
|
-
* @param codecInUse the codec in use.
|
|
3860
|
-
* @param publishOptions the publish options for the track.
|
|
3861
|
-
*/
|
|
3862
|
-
const findOptimalVideoLayers = (videoTrack, targetResolution = defaultTargetResolution, codecInUse, publishOptions) => {
|
|
3863
|
-
const optimalVideoLayers = [];
|
|
3864
|
-
const settings = videoTrack.getSettings();
|
|
3865
|
-
const { width = 0, height = 0 } = settings;
|
|
3866
|
-
const { scalabilityMode, bitrateDownscaleFactor = 2, maxSimulcastLayers = 3, } = publishOptions || {};
|
|
3867
|
-
const maxBitrate = getComputedMaxBitrate(targetResolution, width, height, codecInUse, publishOptions);
|
|
3868
|
-
let downscaleFactor = 1;
|
|
3869
|
-
let bitrateFactor = 1;
|
|
3870
|
-
const svcCodec = isSvcCodec(codecInUse);
|
|
3871
|
-
const totalLayers = svcCodec ? 3 : Math.min(3, maxSimulcastLayers);
|
|
3872
|
-
for (const rid of ['f', 'h', 'q'].slice(0, totalLayers)) {
|
|
3873
|
-
const layer = {
|
|
3874
|
-
active: true,
|
|
3875
|
-
rid,
|
|
3876
|
-
width: Math.round(width / downscaleFactor),
|
|
3877
|
-
height: Math.round(height / downscaleFactor),
|
|
3878
|
-
maxBitrate: Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
|
|
3879
|
-
maxFramerate: 30,
|
|
3880
|
-
};
|
|
3881
|
-
if (svcCodec) {
|
|
3882
|
-
// for SVC codecs, we need to set the scalability mode, and the
|
|
3883
|
-
// codec will handle the rest (layers, temporal layers, etc.)
|
|
3884
|
-
layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
|
|
3885
|
-
}
|
|
3886
|
-
else {
|
|
3887
|
-
// for non-SVC codecs, we need to downscale proportionally (simulcast)
|
|
3888
|
-
layer.scaleResolutionDownBy = downscaleFactor;
|
|
3889
|
-
}
|
|
3890
|
-
downscaleFactor *= 2;
|
|
3891
|
-
bitrateFactor *= bitrateDownscaleFactor;
|
|
3892
|
-
// Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
|
|
3893
|
-
// when deciding which layer to disable when CPU or bandwidth is constrained.
|
|
3894
|
-
// Encodings should be ordered in increasing spatial resolution order.
|
|
3895
|
-
optimalVideoLayers.unshift(layer);
|
|
3896
|
-
}
|
|
3897
|
-
// for simplicity, we start with all layers enabled, then this function
|
|
3898
|
-
// will clear/reassign the layers that are not needed
|
|
3899
|
-
return withSimulcastConstraints(settings, optimalVideoLayers);
|
|
3900
|
-
};
|
|
3901
|
-
/**
|
|
3902
|
-
* Computes the maximum bitrate for a given resolution.
|
|
3903
|
-
* If the current resolution is lower than the target resolution,
|
|
3904
|
-
* we want to proportionally reduce the target bitrate.
|
|
3905
|
-
* If the current resolution is higher than the target resolution,
|
|
3906
|
-
* we want to use the target bitrate.
|
|
3907
|
-
*
|
|
3908
|
-
* @param targetResolution the target resolution.
|
|
3909
|
-
* @param currentWidth the current width of the track.
|
|
3910
|
-
* @param currentHeight the current height of the track.
|
|
3911
|
-
* @param codecInUse the codec in use.
|
|
3912
|
-
* @param publishOptions the publish options.
|
|
3913
|
-
*/
|
|
3914
|
-
const getComputedMaxBitrate = (targetResolution, currentWidth, currentHeight, codecInUse, publishOptions) => {
|
|
3915
|
-
// if the current resolution is lower than the target resolution,
|
|
3916
|
-
// we want to proportionally reduce the target bitrate
|
|
3917
|
-
const { width: targetWidth, height: targetHeight, bitrate: targetBitrate, } = targetResolution;
|
|
3918
|
-
const { preferredBitrate } = publishOptions || {};
|
|
3919
|
-
const frameHeight = currentWidth > currentHeight ? currentHeight : currentWidth;
|
|
3920
|
-
const bitrate = preferredBitrate ||
|
|
3921
|
-
(codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
|
|
3922
|
-
if (currentWidth < targetWidth || currentHeight < targetHeight) {
|
|
3923
|
-
const currentPixels = currentWidth * currentHeight;
|
|
3924
|
-
const targetPixels = targetWidth * targetHeight;
|
|
3925
|
-
const reductionFactor = currentPixels / targetPixels;
|
|
3926
|
-
return Math.round(bitrate * reductionFactor);
|
|
3927
|
-
}
|
|
3928
|
-
return bitrate;
|
|
3929
|
-
};
|
|
3930
|
-
/**
|
|
3931
|
-
* Browsers have different simulcast constraints for different video resolutions.
|
|
3932
|
-
*
|
|
3933
|
-
* This function modifies the provided list of video layers according to the
|
|
3934
|
-
* current implementation of simulcast constraints in the Chromium based browsers.
|
|
3935
|
-
*
|
|
3936
|
-
* https://chromium.googlesource.com/external/webrtc/+/refs/heads/main/media/engine/simulcast.cc#90
|
|
3937
|
-
*/
|
|
3938
|
-
const withSimulcastConstraints = (settings, optimalVideoLayers) => {
|
|
3939
|
-
let layers;
|
|
3940
|
-
const size = Math.max(settings.width || 0, settings.height || 0);
|
|
3941
|
-
if (size <= 320) {
|
|
3942
|
-
// provide only one layer 320x240 (q), the one with the highest quality
|
|
3943
|
-
layers = optimalVideoLayers.filter((layer) => layer.rid === 'f');
|
|
3944
|
-
}
|
|
3945
|
-
else if (size <= 640) {
|
|
3946
|
-
// provide two layers, 160x120 (q) and 640x480 (h)
|
|
3947
|
-
layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
|
|
3948
|
-
}
|
|
3949
|
-
else {
|
|
3950
|
-
// provide three layers for sizes > 640x480
|
|
3951
|
-
layers = optimalVideoLayers;
|
|
3952
|
-
}
|
|
3953
|
-
const ridMapping = ['q', 'h', 'f'];
|
|
3954
|
-
return layers.map((layer, index) => ({
|
|
3955
|
-
...layer,
|
|
3956
|
-
rid: ridMapping[index], // reassign rid
|
|
3957
|
-
}));
|
|
3958
|
-
};
|
|
3959
|
-
const findOptimalScreenSharingLayers = (videoTrack, publishOptions, defaultMaxBitrate = 3000000) => {
|
|
3960
|
-
const { screenShareSettings: preferences } = publishOptions || {};
|
|
3961
|
-
const settings = videoTrack.getSettings();
|
|
3962
|
-
return [
|
|
3963
|
-
{
|
|
3964
|
-
active: true,
|
|
3965
|
-
rid: 'q', // single track, start from 'q'
|
|
3966
|
-
width: settings.width || 0,
|
|
3967
|
-
height: settings.height || 0,
|
|
3968
|
-
scaleResolutionDownBy: 1,
|
|
3969
|
-
maxBitrate: preferences?.maxBitrate ?? defaultMaxBitrate,
|
|
3970
|
-
maxFramerate: preferences?.maxFramerate ?? 30,
|
|
3971
|
-
},
|
|
3972
|
-
];
|
|
3973
|
-
};
|
|
3974
|
-
|
|
3975
|
-
const ensureExhausted = (x, message) => {
|
|
3976
|
-
getLogger(['helpers'])('warn', message, x);
|
|
3977
|
-
};
|
|
3978
|
-
|
|
3979
|
-
const trackTypeToParticipantStreamKey = (trackType) => {
|
|
3980
|
-
switch (trackType) {
|
|
3981
|
-
case TrackType.SCREEN_SHARE:
|
|
3982
|
-
return 'screenShareStream';
|
|
3983
|
-
case TrackType.SCREEN_SHARE_AUDIO:
|
|
3984
|
-
return 'screenShareAudioStream';
|
|
3985
|
-
case TrackType.VIDEO:
|
|
3986
|
-
return 'videoStream';
|
|
3987
|
-
case TrackType.AUDIO:
|
|
3988
|
-
return 'audioStream';
|
|
3989
|
-
case TrackType.UNSPECIFIED:
|
|
3990
|
-
throw new Error('Track type is unspecified');
|
|
3991
|
-
default:
|
|
3992
|
-
ensureExhausted(trackType, 'Unknown track type');
|
|
3993
|
-
}
|
|
3994
|
-
};
|
|
3995
|
-
const muteTypeToTrackType = (muteType) => {
|
|
3996
|
-
switch (muteType) {
|
|
3997
|
-
case 'audio':
|
|
3998
|
-
return TrackType.AUDIO;
|
|
3999
|
-
case 'video':
|
|
4000
|
-
return TrackType.VIDEO;
|
|
4001
|
-
case 'screenshare':
|
|
4002
|
-
return TrackType.SCREEN_SHARE;
|
|
4003
|
-
case 'screenshare_audio':
|
|
4004
|
-
return TrackType.SCREEN_SHARE_AUDIO;
|
|
4005
|
-
default:
|
|
4006
|
-
ensureExhausted(muteType, 'Unknown mute type');
|
|
4007
|
-
}
|
|
4008
|
-
};
|
|
4009
|
-
const toTrackType = (trackType) => {
|
|
4010
|
-
switch (trackType) {
|
|
4011
|
-
case 'TRACK_TYPE_AUDIO':
|
|
4012
|
-
return TrackType.AUDIO;
|
|
4013
|
-
case 'TRACK_TYPE_VIDEO':
|
|
4014
|
-
return TrackType.VIDEO;
|
|
4015
|
-
case 'TRACK_TYPE_SCREEN_SHARE':
|
|
4016
|
-
return TrackType.SCREEN_SHARE;
|
|
4017
|
-
case 'TRACK_TYPE_SCREEN_SHARE_AUDIO':
|
|
4018
|
-
return TrackType.SCREEN_SHARE_AUDIO;
|
|
4019
|
-
default:
|
|
4020
|
-
return undefined;
|
|
3564
|
+
catch (e) {
|
|
3565
|
+
const logger = getLogger(['sfu-client']);
|
|
3566
|
+
logger('error', `Failed to parse ICE Trickle`, e, iceTrickle);
|
|
3567
|
+
return undefined;
|
|
4021
3568
|
}
|
|
4022
3569
|
};
|
|
4023
3570
|
|
|
@@ -5585,198 +5132,446 @@ class CallState {
|
|
|
5585
5132
|
}
|
|
5586
5133
|
}
|
|
5587
5134
|
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
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);
|
|
5598
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);
|
|
5599
5278
|
}
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
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;
|
|
5611
5339
|
};
|
|
5340
|
+
/**
|
|
5341
|
+
* Init index of the transceiver in the cache.
|
|
5342
|
+
*/
|
|
5343
|
+
this.indexOf = (transceiver) => {
|
|
5344
|
+
return this.transceiverOrder.indexOf(transceiver);
|
|
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');
|
|
5395
|
+
}
|
|
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;
|
|
5612
5423
|
}
|
|
5613
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
|
+
};
|
|
5614
5432
|
/**
|
|
5615
|
-
*
|
|
5616
|
-
*
|
|
5617
|
-
*
|
|
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.
|
|
5618
5438
|
*/
|
|
5619
|
-
const
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
}
|
|
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' }];
|
|
5629
5448
|
};
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
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);
|
|
5650
5505
|
}
|
|
5651
|
-
else
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
if (rtpMapLine) {
|
|
5655
|
-
rtpMap.push(rtpMapLine);
|
|
5656
|
-
}
|
|
5657
|
-
else if (fmtpLine) {
|
|
5658
|
-
fmtp.push(fmtpLine);
|
|
5659
|
-
}
|
|
5506
|
+
else {
|
|
5507
|
+
// for non-SVC codecs, we need to downscale proportionally (simulcast)
|
|
5508
|
+
layer.scaleResolutionDownBy = downscaleFactor;
|
|
5660
5509
|
}
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
};
|
|
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);
|
|
5668
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);
|
|
5669
5520
|
};
|
|
5670
5521
|
/**
|
|
5671
|
-
*
|
|
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.
|
|
5672
5532
|
*/
|
|
5673
|
-
const
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
const
|
|
5677
|
-
if (
|
|
5678
|
-
|
|
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);
|
|
5679
5542
|
}
|
|
5543
|
+
return bitrate;
|
|
5680
5544
|
};
|
|
5681
5545
|
/**
|
|
5682
|
-
*
|
|
5683
|
-
*/
|
|
5684
|
-
const toggleDtx = (sdp, enable) => {
|
|
5685
|
-
const opusFmtp = getOpusFmtp(sdp);
|
|
5686
|
-
if (!opusFmtp)
|
|
5687
|
-
return sdp;
|
|
5688
|
-
const matchDtx = /usedtx=(\d)/.exec(opusFmtp.config);
|
|
5689
|
-
const requiredDtxConfig = `usedtx=${enable ? '1' : '0'}`;
|
|
5690
|
-
const newFmtp = matchDtx
|
|
5691
|
-
? opusFmtp.original.replace(/usedtx=(\d)/, requiredDtxConfig)
|
|
5692
|
-
: `${opusFmtp.original};${requiredDtxConfig}`;
|
|
5693
|
-
return sdp.replace(opusFmtp.original, newFmtp);
|
|
5694
|
-
};
|
|
5695
|
-
/**
|
|
5696
|
-
* Returns and SDP with all the codecs except the given codec removed.
|
|
5697
|
-
*/
|
|
5698
|
-
const preserveCodec = (sdp, mid, codec) => {
|
|
5699
|
-
const [kind, codecName] = codec.mimeType.toLowerCase().split('/');
|
|
5700
|
-
const toSet = (fmtpLine) => new Set(fmtpLine.split(';').map((f) => f.trim().toLowerCase()));
|
|
5701
|
-
const equal = (a, b) => {
|
|
5702
|
-
if (a.size !== b.size)
|
|
5703
|
-
return false;
|
|
5704
|
-
for (const item of a)
|
|
5705
|
-
if (!b.has(item))
|
|
5706
|
-
return false;
|
|
5707
|
-
return true;
|
|
5708
|
-
};
|
|
5709
|
-
const codecFmtp = toSet(codec.sdpFmtpLine || '');
|
|
5710
|
-
const parsedSdp = SDP.parse(sdp);
|
|
5711
|
-
for (const media of parsedSdp.media) {
|
|
5712
|
-
if (media.type !== kind || String(media.mid) !== mid)
|
|
5713
|
-
continue;
|
|
5714
|
-
// find the payload id of the desired codec
|
|
5715
|
-
const payloads = new Set();
|
|
5716
|
-
for (const rtp of media.rtp) {
|
|
5717
|
-
if (rtp.codec.toLowerCase() !== codecName)
|
|
5718
|
-
continue;
|
|
5719
|
-
const match =
|
|
5720
|
-
// vp8 doesn't have any fmtp, we preserve it without any additional checks
|
|
5721
|
-
codecName === 'vp8'
|
|
5722
|
-
? true
|
|
5723
|
-
: media.fmtp.some((f) => f.payload === rtp.payload && equal(toSet(f.config), codecFmtp));
|
|
5724
|
-
if (match) {
|
|
5725
|
-
payloads.add(rtp.payload);
|
|
5726
|
-
}
|
|
5727
|
-
}
|
|
5728
|
-
// find the corresponding rtx codec by matching apt=<preserved-codec-payload>
|
|
5729
|
-
for (const fmtp of media.fmtp) {
|
|
5730
|
-
const match = fmtp.config.match(/(apt)=(\d+)/);
|
|
5731
|
-
if (!match)
|
|
5732
|
-
continue;
|
|
5733
|
-
const [, , preservedCodecPayload] = match;
|
|
5734
|
-
if (payloads.has(Number(preservedCodecPayload))) {
|
|
5735
|
-
payloads.add(fmtp.payload);
|
|
5736
|
-
}
|
|
5737
|
-
}
|
|
5738
|
-
media.rtp = media.rtp.filter((r) => payloads.has(r.payload));
|
|
5739
|
-
media.fmtp = media.fmtp.filter((f) => payloads.has(f.payload));
|
|
5740
|
-
media.rtcpFb = media.rtcpFb?.filter((f) => payloads.has(f.payload));
|
|
5741
|
-
media.payloads = Array.from(payloads).join(' ');
|
|
5742
|
-
}
|
|
5743
|
-
return SDP.write(parsedSdp);
|
|
5744
|
-
};
|
|
5745
|
-
/**
|
|
5746
|
-
* Enables high-quality audio through SDP munging for the given trackMid.
|
|
5546
|
+
* Browsers have different simulcast constraints for different video resolutions.
|
|
5747
5547
|
*
|
|
5748
|
-
*
|
|
5749
|
-
*
|
|
5750
|
-
*
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
const
|
|
5756
|
-
if (
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
if (!opusRtp)
|
|
5760
|
-
return sdp;
|
|
5761
|
-
const opusFmtp = audioMedia.fmtp.find((f) => f.payload === opusRtp.payload);
|
|
5762
|
-
if (!opusFmtp)
|
|
5763
|
-
return sdp;
|
|
5764
|
-
// enable stereo, if not already enabled
|
|
5765
|
-
if (opusFmtp.config.match(/stereo=(\d)/)) {
|
|
5766
|
-
opusFmtp.config = opusFmtp.config.replace(/stereo=(\d)/, 'stereo=1');
|
|
5767
|
-
}
|
|
5768
|
-
else {
|
|
5769
|
-
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');
|
|
5770
5559
|
}
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5560
|
+
else if (size <= 640) {
|
|
5561
|
+
// provide two layers, 160x120 (q) and 640x480 (h)
|
|
5562
|
+
layers = optimalVideoLayers.filter((layer) => layer.rid !== 'h');
|
|
5774
5563
|
}
|
|
5775
5564
|
else {
|
|
5776
|
-
|
|
5565
|
+
// provide three layers for sizes > 640x480
|
|
5566
|
+
layers = optimalVideoLayers;
|
|
5777
5567
|
}
|
|
5778
|
-
|
|
5568
|
+
const ridMapping = ['q', 'h', 'f'];
|
|
5569
|
+
return layers.map((layer, index) => ({
|
|
5570
|
+
...layer,
|
|
5571
|
+
rid: ridMapping[index], // reassign rid
|
|
5572
|
+
}));
|
|
5779
5573
|
};
|
|
5574
|
+
|
|
5780
5575
|
/**
|
|
5781
5576
|
* Extracts the mid from the transceiver or the SDP.
|
|
5782
5577
|
*
|
|
@@ -5788,9 +5583,9 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
|
|
|
5788
5583
|
if (transceiver.mid)
|
|
5789
5584
|
return transceiver.mid;
|
|
5790
5585
|
if (!sdp)
|
|
5791
|
-
return
|
|
5586
|
+
return String(transceiverInitIndex);
|
|
5792
5587
|
const track = transceiver.sender.track;
|
|
5793
|
-
const parsedSdp =
|
|
5588
|
+
const parsedSdp = parse(sdp);
|
|
5794
5589
|
const media = parsedSdp.media.find((m) => {
|
|
5795
5590
|
return (m.type === track.kind &&
|
|
5796
5591
|
// if `msid` is not present, we assume that the track is the first one
|
|
@@ -5798,7 +5593,7 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
|
|
|
5798
5593
|
});
|
|
5799
5594
|
if (typeof media?.mid !== 'undefined')
|
|
5800
5595
|
return String(media.mid);
|
|
5801
|
-
if (transceiverInitIndex
|
|
5596
|
+
if (transceiverInitIndex < 0)
|
|
5802
5597
|
return '';
|
|
5803
5598
|
return String(transceiverInitIndex);
|
|
5804
5599
|
};
|
|
@@ -5808,164 +5603,87 @@ const extractMid = (transceiver, transceiverInitIndex, sdp) => {
|
|
|
5808
5603
|
*
|
|
5809
5604
|
* @internal
|
|
5810
5605
|
*/
|
|
5811
|
-
class Publisher {
|
|
5606
|
+
class Publisher extends BasePeerConnection {
|
|
5812
5607
|
/**
|
|
5813
5608
|
* Constructs a new `Publisher` instance.
|
|
5814
5609
|
*/
|
|
5815
|
-
constructor({
|
|
5816
|
-
|
|
5817
|
-
this.
|
|
5818
|
-
this.publishOptsForTrack = new Map();
|
|
5819
|
-
/**
|
|
5820
|
-
* An array maintaining the order how transceivers were added to the peer connection.
|
|
5821
|
-
* This is needed because some browsers (Firefox) don't reliably report
|
|
5822
|
-
* trackId and `mid` parameters.
|
|
5823
|
-
*
|
|
5824
|
-
* @internal
|
|
5825
|
-
*/
|
|
5826
|
-
this.transceiverInitOrder = [];
|
|
5827
|
-
this.isIceRestarting = false;
|
|
5828
|
-
this.createPeerConnection = (connectionConfig) => {
|
|
5829
|
-
const pc = new RTCPeerConnection(connectionConfig);
|
|
5830
|
-
pc.addEventListener('icecandidate', this.onIceCandidate);
|
|
5831
|
-
pc.addEventListener('negotiationneeded', this.onNegotiationNeeded);
|
|
5832
|
-
pc.addEventListener('icecandidateerror', this.onIceCandidateError);
|
|
5833
|
-
pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
5834
|
-
pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
|
|
5835
|
-
pc.addEventListener('signalingstatechange', this.onSignalingStateChange);
|
|
5836
|
-
return pc;
|
|
5837
|
-
};
|
|
5838
|
-
/**
|
|
5839
|
-
* Closes the publisher PeerConnection and cleans up the resources.
|
|
5840
|
-
*/
|
|
5841
|
-
this.close = ({ stopTracks }) => {
|
|
5842
|
-
if (stopTracks) {
|
|
5843
|
-
this.stopPublishing();
|
|
5844
|
-
this.transceiverCache.clear();
|
|
5845
|
-
this.trackLayersCache.clear();
|
|
5846
|
-
}
|
|
5847
|
-
this.detachEventHandlers();
|
|
5848
|
-
this.pc.close();
|
|
5849
|
-
};
|
|
5850
|
-
/**
|
|
5851
|
-
* Detaches the event handlers from the `RTCPeerConnection`.
|
|
5852
|
-
* This is useful when we want to replace the `RTCPeerConnection`
|
|
5853
|
-
* instance with a new one (in case of migration).
|
|
5854
|
-
*/
|
|
5855
|
-
this.detachEventHandlers = () => {
|
|
5856
|
-
this.unsubscribeOnIceRestart();
|
|
5857
|
-
this.unsubscribeChangePublishQuality();
|
|
5858
|
-
this.pc.removeEventListener('icecandidate', this.onIceCandidate);
|
|
5859
|
-
this.pc.removeEventListener('negotiationneeded', this.onNegotiationNeeded);
|
|
5860
|
-
this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
|
|
5861
|
-
this.pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
5862
|
-
this.pc.removeEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
|
|
5863
|
-
this.pc.removeEventListener('signalingstatechange', this.onSignalingStateChange);
|
|
5864
|
-
};
|
|
5610
|
+
constructor({ publishOptions, ...baseOptions }) {
|
|
5611
|
+
super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
|
|
5612
|
+
this.transceiverCache = new TransceiverCache();
|
|
5865
5613
|
/**
|
|
5866
5614
|
* Starts publishing the given track of the given media stream.
|
|
5867
5615
|
*
|
|
5868
5616
|
* Consecutive calls to this method will replace the stream.
|
|
5869
5617
|
* The previous stream will be stopped.
|
|
5870
5618
|
*
|
|
5871
|
-
* @param mediaStream the media stream to publish.
|
|
5872
5619
|
* @param track the track to publish.
|
|
5873
5620
|
* @param trackType the track type to publish.
|
|
5874
|
-
* @param opts the optional publish options to use.
|
|
5875
5621
|
*/
|
|
5876
|
-
this.
|
|
5877
|
-
if (
|
|
5878
|
-
throw new Error(`
|
|
5879
|
-
}
|
|
5880
|
-
// enable the track if it is disabled
|
|
5881
|
-
if (!track.enabled)
|
|
5882
|
-
track.enabled = true;
|
|
5883
|
-
const transceiver = this.transceiverCache.get(trackType);
|
|
5884
|
-
if (!transceiver || !transceiver.sender.track) {
|
|
5885
|
-
// listen for 'ended' event on the track as it might be ended abruptly
|
|
5886
|
-
// by an external factors such as permission revokes, a disconnected device, etc.
|
|
5887
|
-
// keep in mind that `track.stop()` doesn't trigger this event.
|
|
5888
|
-
const handleTrackEnded = () => {
|
|
5889
|
-
this.logger('info', `Track ${TrackType[trackType]} has ended abruptly`);
|
|
5890
|
-
track.removeEventListener('ended', handleTrackEnded);
|
|
5891
|
-
this.notifyTrackMuteStateChanged(mediaStream, trackType, true).catch((err) => this.logger('warn', `Couldn't notify track mute state`, err));
|
|
5892
|
-
};
|
|
5893
|
-
track.addEventListener('ended', handleTrackEnded);
|
|
5894
|
-
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]}`);
|
|
5895
5625
|
}
|
|
5896
|
-
|
|
5897
|
-
|
|
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
|
+
}
|
|
5898
5639
|
}
|
|
5899
|
-
await this.notifyTrackMuteStateChanged(mediaStream, trackType, false);
|
|
5900
5640
|
};
|
|
5901
5641
|
/**
|
|
5902
|
-
* Adds a new transceiver to the peer connection.
|
|
5903
|
-
* This needs to be called when a new track kind is added to the peer connection.
|
|
5904
|
-
* In other cases, use `updateTransceiver` method.
|
|
5642
|
+
* Adds a new transceiver carrying the given track to the peer connection.
|
|
5905
5643
|
*/
|
|
5906
|
-
this.addTransceiver = (
|
|
5907
|
-
const
|
|
5908
|
-
const
|
|
5909
|
-
|
|
5644
|
+
this.addTransceiver = (track, publishOption) => {
|
|
5645
|
+
const videoEncodings = computeVideoLayers(track, publishOption);
|
|
5646
|
+
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
5647
|
+
? toSvcEncodings(videoEncodings)
|
|
5648
|
+
: videoEncodings;
|
|
5910
5649
|
const transceiver = this.pc.addTransceiver(track, {
|
|
5911
5650
|
direction: 'sendonly',
|
|
5912
|
-
|
|
5913
|
-
? [mediaStream]
|
|
5914
|
-
: undefined,
|
|
5915
|
-
sendEncodings: isSvcCodec(codecInUse)
|
|
5916
|
-
? toSvcEncodings(videoEncodings)
|
|
5917
|
-
: videoEncodings,
|
|
5651
|
+
sendEncodings,
|
|
5918
5652
|
});
|
|
5653
|
+
const trackType = publishOption.trackType;
|
|
5919
5654
|
this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
|
|
5920
|
-
this.
|
|
5921
|
-
this.transceiverCache.set(trackType, transceiver);
|
|
5922
|
-
this.publishOptsForTrack.set(trackType, opts);
|
|
5923
|
-
// handle codec preferences
|
|
5924
|
-
if (!('setCodecPreferences' in transceiver))
|
|
5925
|
-
return;
|
|
5926
|
-
const codecPreferences = this.getCodecPreferences(trackType, trackType === TrackType.VIDEO ? codecInUse : undefined, 'receiver');
|
|
5927
|
-
if (!codecPreferences)
|
|
5928
|
-
return;
|
|
5929
|
-
try {
|
|
5930
|
-
this.logger('info', `Setting ${TrackType[trackType]} codec preferences`, codecPreferences);
|
|
5931
|
-
transceiver.setCodecPreferences(codecPreferences);
|
|
5932
|
-
}
|
|
5933
|
-
catch (err) {
|
|
5934
|
-
this.logger('warn', `Couldn't set codec preferences`, err);
|
|
5935
|
-
}
|
|
5936
|
-
};
|
|
5937
|
-
/**
|
|
5938
|
-
* Updates the given transceiver with the new track.
|
|
5939
|
-
* Stops the previous track and replaces it with the new one.
|
|
5940
|
-
*/
|
|
5941
|
-
this.updateTransceiver = async (transceiver, track) => {
|
|
5942
|
-
const previousTrack = transceiver.sender.track;
|
|
5943
|
-
// don't stop the track if we are re-publishing the same track
|
|
5944
|
-
if (previousTrack && previousTrack !== track) {
|
|
5945
|
-
previousTrack.stop();
|
|
5946
|
-
}
|
|
5947
|
-
await transceiver.sender.replaceTrack(track);
|
|
5655
|
+
this.transceiverCache.add(publishOption, transceiver);
|
|
5948
5656
|
};
|
|
5949
5657
|
/**
|
|
5950
|
-
*
|
|
5951
|
-
* Underlying track will be stopped and removed from the publisher.
|
|
5952
|
-
* @param trackType the track type to unpublish.
|
|
5953
|
-
* @param stopTrack specifies whether track should be stopped or just disabled
|
|
5658
|
+
* Synchronizes the current Publisher state with the provided publish options.
|
|
5954
5659
|
*/
|
|
5955
|
-
this.
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
(
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
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);
|
|
5969
5687
|
}
|
|
5970
5688
|
};
|
|
5971
5689
|
/**
|
|
@@ -5974,57 +5692,52 @@ class Publisher {
|
|
|
5974
5692
|
* @param trackType the track type to check.
|
|
5975
5693
|
*/
|
|
5976
5694
|
this.isPublishing = (trackType) => {
|
|
5977
|
-
const
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
const audioOrVideoOrScreenShareStream = trackTypeToParticipantStreamKey(trackType);
|
|
5986
|
-
if (!audioOrVideoOrScreenShareStream)
|
|
5987
|
-
return;
|
|
5988
|
-
if (isMuted) {
|
|
5989
|
-
this.state.updateParticipant(this.sfuClient.sessionId, (p) => ({
|
|
5990
|
-
publishedTracks: p.publishedTracks.filter((t) => t !== trackType),
|
|
5991
|
-
[audioOrVideoOrScreenShareStream]: undefined,
|
|
5992
|
-
}));
|
|
5993
|
-
}
|
|
5994
|
-
else {
|
|
5995
|
-
this.state.updateParticipant(this.sfuClient.sessionId, (p) => {
|
|
5996
|
-
return {
|
|
5997
|
-
publishedTracks: p.publishedTracks.includes(trackType)
|
|
5998
|
-
? p.publishedTracks
|
|
5999
|
-
: [...p.publishedTracks, trackType],
|
|
6000
|
-
[audioOrVideoOrScreenShareStream]: mediaStream,
|
|
6001
|
-
};
|
|
6002
|
-
});
|
|
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;
|
|
6003
5703
|
}
|
|
5704
|
+
return false;
|
|
6004
5705
|
};
|
|
6005
5706
|
/**
|
|
6006
|
-
*
|
|
5707
|
+
* Maps the given track ID to the corresponding track type.
|
|
6007
5708
|
*/
|
|
6008
|
-
this.
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
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;
|
|
6014
5714
|
}
|
|
6015
|
-
}
|
|
5715
|
+
}
|
|
5716
|
+
return undefined;
|
|
6016
5717
|
};
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
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.`);
|
|
6023
5737
|
}
|
|
6024
|
-
const params =
|
|
5738
|
+
const params = sender.getParameters();
|
|
6025
5739
|
if (params.encodings.length === 0) {
|
|
6026
|
-
this.logger('warn',
|
|
6027
|
-
return;
|
|
5740
|
+
return this.logger('warn', `${tag} there are no encodings set.`);
|
|
6028
5741
|
}
|
|
6029
5742
|
const [codecInUse] = params.codecs;
|
|
6030
5743
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse.mimeType);
|
|
@@ -6066,54 +5779,12 @@ class Publisher {
|
|
|
6066
5779
|
changed = true;
|
|
6067
5780
|
}
|
|
6068
5781
|
}
|
|
6069
|
-
const
|
|
5782
|
+
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
6070
5783
|
if (!changed) {
|
|
6071
|
-
this.logger('info',
|
|
6072
|
-
return;
|
|
6073
|
-
}
|
|
6074
|
-
await videoSender.setParameters(params);
|
|
6075
|
-
this.logger('info', `Update publish quality, enabled rids:`, activeLayers);
|
|
6076
|
-
};
|
|
6077
|
-
/**
|
|
6078
|
-
* Returns the result of the `RTCPeerConnection.getStats()` method
|
|
6079
|
-
* @param selector
|
|
6080
|
-
* @returns
|
|
6081
|
-
*/
|
|
6082
|
-
this.getStats = (selector) => {
|
|
6083
|
-
return this.pc.getStats(selector);
|
|
6084
|
-
};
|
|
6085
|
-
this.getCodecPreferences = (trackType, preferredCodec, codecPreferencesSource) => {
|
|
6086
|
-
if (trackType === TrackType.VIDEO) {
|
|
6087
|
-
return getPreferredCodecs('video', preferredCodec || 'vp8', undefined, codecPreferencesSource);
|
|
6088
|
-
}
|
|
6089
|
-
if (trackType === TrackType.AUDIO) {
|
|
6090
|
-
const defaultAudioCodec = this.isRedEnabled ? 'red' : 'opus';
|
|
6091
|
-
const codecToRemove = !this.isRedEnabled ? 'red' : undefined;
|
|
6092
|
-
return getPreferredCodecs('audio', preferredCodec ?? defaultAudioCodec, codecToRemove, codecPreferencesSource);
|
|
6093
|
-
}
|
|
6094
|
-
};
|
|
6095
|
-
this.onIceCandidate = (e) => {
|
|
6096
|
-
const { candidate } = e;
|
|
6097
|
-
if (!candidate) {
|
|
6098
|
-
this.logger('debug', 'null ice candidate');
|
|
6099
|
-
return;
|
|
5784
|
+
return this.logger('info', `${tag} no change:`, activeEncoders);
|
|
6100
5785
|
}
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
iceCandidate: getIceCandidate(candidate),
|
|
6104
|
-
peerType: PeerType.PUBLISHER_UNSPECIFIED,
|
|
6105
|
-
})
|
|
6106
|
-
.catch((err) => {
|
|
6107
|
-
this.logger('warn', `ICETrickle failed`, err);
|
|
6108
|
-
});
|
|
6109
|
-
};
|
|
6110
|
-
/**
|
|
6111
|
-
* Sets the SFU client to use.
|
|
6112
|
-
*
|
|
6113
|
-
* @param sfuClient the SFU client to use.
|
|
6114
|
-
*/
|
|
6115
|
-
this.setSfuClient = (sfuClient) => {
|
|
6116
|
-
this.sfuClient = sfuClient;
|
|
5786
|
+
await sender.setParameters(params);
|
|
5787
|
+
this.logger('info', `${tag} enabled rids:`, activeEncoders);
|
|
6117
5788
|
};
|
|
6118
5789
|
/**
|
|
6119
5790
|
* Restarts the ICE connection and renegotiates with the SFU.
|
|
@@ -6128,7 +5799,7 @@ class Publisher {
|
|
|
6128
5799
|
await this.negotiate({ iceRestart: true });
|
|
6129
5800
|
};
|
|
6130
5801
|
this.onNegotiationNeeded = () => {
|
|
6131
|
-
this.negotiate().catch((err) => {
|
|
5802
|
+
withoutConcurrency('publisher.negotiate', () => this.negotiate()).catch((err) => {
|
|
6132
5803
|
this.logger('error', `Negotiation failed.`, err);
|
|
6133
5804
|
this.onUnrecoverableError?.();
|
|
6134
5805
|
});
|
|
@@ -6140,18 +5811,6 @@ class Publisher {
|
|
|
6140
5811
|
*/
|
|
6141
5812
|
this.negotiate = async (options) => {
|
|
6142
5813
|
const offer = await this.pc.createOffer(options);
|
|
6143
|
-
if (offer.sdp) {
|
|
6144
|
-
offer.sdp = toggleDtx(offer.sdp, this.isDtxEnabled);
|
|
6145
|
-
if (this.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
|
|
6146
|
-
offer.sdp = this.enableHighQualityAudio(offer.sdp);
|
|
6147
|
-
}
|
|
6148
|
-
if (this.isPublishing(TrackType.VIDEO)) {
|
|
6149
|
-
// Hotfix for platforms that don't respect the ordered codec list
|
|
6150
|
-
// (Firefox, Android, Linux, etc...).
|
|
6151
|
-
// We remove all the codecs from the SDP except the one we want to use.
|
|
6152
|
-
offer.sdp = this.removeUnpreferredCodecs(offer.sdp, TrackType.VIDEO);
|
|
6153
|
-
}
|
|
6154
|
-
}
|
|
6155
5814
|
const trackInfos = this.getAnnouncedTracks(offer.sdp);
|
|
6156
5815
|
if (trackInfos.length === 0) {
|
|
6157
5816
|
throw new Error(`Can't negotiate without announcing any tracks`);
|
|
@@ -6170,238 +5829,121 @@ class Publisher {
|
|
|
6170
5829
|
finally {
|
|
6171
5830
|
this.isIceRestarting = false;
|
|
6172
5831
|
}
|
|
6173
|
-
this.
|
|
6174
|
-
try {
|
|
6175
|
-
const iceCandidate = JSON.parse(candidate.iceCandidate);
|
|
6176
|
-
await this.pc.addIceCandidate(iceCandidate);
|
|
6177
|
-
}
|
|
6178
|
-
catch (e) {
|
|
6179
|
-
this.logger('warn', `ICE candidate error`, e, candidate);
|
|
6180
|
-
}
|
|
6181
|
-
});
|
|
6182
|
-
};
|
|
6183
|
-
this.enableHighQualityAudio = (sdp) => {
|
|
6184
|
-
const transceiver = this.transceiverCache.get(TrackType.SCREEN_SHARE_AUDIO);
|
|
6185
|
-
if (!transceiver)
|
|
6186
|
-
return sdp;
|
|
6187
|
-
const transceiverInitIndex = this.transceiverInitOrder.indexOf(TrackType.SCREEN_SHARE_AUDIO);
|
|
6188
|
-
const mid = extractMid(transceiver, transceiverInitIndex, sdp);
|
|
6189
|
-
return enableHighQualityAudio(sdp, mid);
|
|
5832
|
+
this.addTrickledIceCandidates();
|
|
6190
5833
|
};
|
|
6191
5834
|
/**
|
|
6192
5835
|
* Returns a list of tracks that are currently being published.
|
|
6193
|
-
*
|
|
6194
|
-
* @internal
|
|
6195
|
-
* @param sdp an optional SDP to extract the `mid` from.
|
|
6196
5836
|
*/
|
|
6197
|
-
this.
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
.getTransceivers()
|
|
6201
|
-
.filter((t) => t.direction === 'sendonly' && t.sender.track)
|
|
6202
|
-
.map((transceiver) => {
|
|
6203
|
-
let trackType;
|
|
6204
|
-
this.transceiverCache.forEach((value, key) => {
|
|
6205
|
-
if (value === transceiver)
|
|
6206
|
-
trackType = key;
|
|
6207
|
-
});
|
|
5837
|
+
this.getPublishedTracks = () => {
|
|
5838
|
+
const tracks = [];
|
|
5839
|
+
for (const { transceiver } of this.transceiverCache.items()) {
|
|
6208
5840
|
const track = transceiver.sender.track;
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
if (isTrackLive) {
|
|
6212
|
-
optimalLayers = this.computeLayers(trackType, track) || [];
|
|
6213
|
-
this.trackLayersCache.set(trackType, optimalLayers);
|
|
6214
|
-
}
|
|
6215
|
-
else {
|
|
6216
|
-
// we report the last known optimal layers for ended tracks
|
|
6217
|
-
optimalLayers = this.trackLayersCache.get(trackType) || [];
|
|
6218
|
-
this.logger('debug', `Track ${TrackType[trackType]} is ended. Announcing last known optimal layers`, optimalLayers);
|
|
6219
|
-
}
|
|
6220
|
-
const layers = optimalLayers.map((optimalLayer) => ({
|
|
6221
|
-
rid: optimalLayer.rid || '',
|
|
6222
|
-
bitrate: optimalLayer.maxBitrate || 0,
|
|
6223
|
-
fps: optimalLayer.maxFramerate || 0,
|
|
6224
|
-
quality: ridToVideoQuality(optimalLayer.rid || ''),
|
|
6225
|
-
videoDimension: {
|
|
6226
|
-
width: optimalLayer.width,
|
|
6227
|
-
height: optimalLayer.height,
|
|
6228
|
-
},
|
|
6229
|
-
}));
|
|
6230
|
-
const isAudioTrack = [
|
|
6231
|
-
TrackType.AUDIO,
|
|
6232
|
-
TrackType.SCREEN_SHARE_AUDIO,
|
|
6233
|
-
].includes(trackType);
|
|
6234
|
-
const trackSettings = track.getSettings();
|
|
6235
|
-
const isStereo = isAudioTrack && trackSettings.channelCount === 2;
|
|
6236
|
-
const transceiverInitIndex = this.transceiverInitOrder.indexOf(trackType);
|
|
6237
|
-
return {
|
|
6238
|
-
trackId: track.id,
|
|
6239
|
-
layers: layers,
|
|
6240
|
-
trackType,
|
|
6241
|
-
mid: extractMid(transceiver, transceiverInitIndex, sdp),
|
|
6242
|
-
stereo: isStereo,
|
|
6243
|
-
dtx: isAudioTrack && this.isDtxEnabled,
|
|
6244
|
-
red: isAudioTrack && this.isRedEnabled,
|
|
6245
|
-
muted: !isTrackLive,
|
|
6246
|
-
};
|
|
6247
|
-
});
|
|
6248
|
-
};
|
|
6249
|
-
this.computeLayers = (trackType, track, opts) => {
|
|
6250
|
-
const { settings } = this.state;
|
|
6251
|
-
const targetResolution = settings?.video
|
|
6252
|
-
.target_resolution;
|
|
6253
|
-
const screenShareBitrate = settings?.screensharing.target_resolution?.bitrate;
|
|
6254
|
-
const publishOpts = opts || this.publishOptsForTrack.get(trackType);
|
|
6255
|
-
const codecInUse = opts?.forceCodec || getOptimalVideoCodec(opts?.preferredCodec);
|
|
6256
|
-
return trackType === TrackType.VIDEO
|
|
6257
|
-
? findOptimalVideoLayers(track, targetResolution, codecInUse, publishOpts)
|
|
6258
|
-
: trackType === TrackType.SCREEN_SHARE
|
|
6259
|
-
? findOptimalScreenSharingLayers(track, publishOpts, screenShareBitrate)
|
|
6260
|
-
: undefined;
|
|
6261
|
-
};
|
|
6262
|
-
this.onIceCandidateError = (e) => {
|
|
6263
|
-
const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
|
|
6264
|
-
`${e.errorCode}: ${e.errorText}`;
|
|
6265
|
-
const iceState = this.pc.iceConnectionState;
|
|
6266
|
-
const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
|
|
6267
|
-
this.logger(logLevel, `ICE Candidate error`, errorMessage);
|
|
6268
|
-
};
|
|
6269
|
-
this.onIceConnectionStateChange = () => {
|
|
6270
|
-
const state = this.pc.iceConnectionState;
|
|
6271
|
-
this.logger('debug', `ICE Connection state changed to`, state);
|
|
6272
|
-
if (this.state.callingState === CallingState.RECONNECTING)
|
|
6273
|
-
return;
|
|
6274
|
-
if (state === 'failed' || state === 'disconnected') {
|
|
6275
|
-
this.logger('debug', `Attempting to restart ICE`);
|
|
6276
|
-
this.restartIce().catch((e) => {
|
|
6277
|
-
this.logger('error', `ICE restart error`, e);
|
|
6278
|
-
this.onUnrecoverableError?.();
|
|
6279
|
-
});
|
|
5841
|
+
if (track && track.readyState === 'live')
|
|
5842
|
+
tracks.push(track);
|
|
6280
5843
|
}
|
|
6281
|
-
|
|
6282
|
-
this.onIceGatheringStateChange = () => {
|
|
6283
|
-
this.logger('debug', `ICE Gathering State`, this.pc.iceGatheringState);
|
|
6284
|
-
};
|
|
6285
|
-
this.onSignalingStateChange = () => {
|
|
6286
|
-
this.logger('debug', `Signaling state changed`, this.pc.signalingState);
|
|
6287
|
-
};
|
|
6288
|
-
this.logger = getLogger(['Publisher', logTag]);
|
|
6289
|
-
this.pc = this.createPeerConnection(connectionConfig);
|
|
6290
|
-
this.sfuClient = sfuClient;
|
|
6291
|
-
this.state = state;
|
|
6292
|
-
this.isDtxEnabled = isDtxEnabled;
|
|
6293
|
-
this.isRedEnabled = isRedEnabled;
|
|
6294
|
-
this.onUnrecoverableError = onUnrecoverableError;
|
|
6295
|
-
this.unsubscribeOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
|
|
6296
|
-
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
|
|
6297
|
-
return;
|
|
6298
|
-
this.restartIce().catch((err) => {
|
|
6299
|
-
this.logger('warn', `ICERestart failed`, err);
|
|
6300
|
-
this.onUnrecoverableError?.();
|
|
6301
|
-
});
|
|
6302
|
-
});
|
|
6303
|
-
this.unsubscribeChangePublishQuality = dispatcher.on('changePublishQuality', ({ videoSenders }) => {
|
|
6304
|
-
withoutConcurrency('publisher.changePublishQuality', async () => {
|
|
6305
|
-
for (const videoSender of videoSenders) {
|
|
6306
|
-
const { layers } = videoSender;
|
|
6307
|
-
const enabledLayers = layers.filter((l) => l.active);
|
|
6308
|
-
await this.changePublishQuality(enabledLayers);
|
|
6309
|
-
}
|
|
6310
|
-
}).catch((err) => {
|
|
6311
|
-
this.logger('warn', 'Failed to change publish quality', err);
|
|
6312
|
-
});
|
|
6313
|
-
});
|
|
6314
|
-
}
|
|
6315
|
-
removeUnpreferredCodecs(sdp, trackType) {
|
|
6316
|
-
const opts = this.publishOptsForTrack.get(trackType);
|
|
6317
|
-
const forceSingleCodec = !!opts?.forceSingleCodec || isReactNative() || isFirefox();
|
|
6318
|
-
if (!opts || !forceSingleCodec)
|
|
6319
|
-
return sdp;
|
|
6320
|
-
const codec = opts.forceCodec || getOptimalVideoCodec(opts.preferredCodec);
|
|
6321
|
-
const orderedCodecs = this.getCodecPreferences(trackType, codec, 'sender');
|
|
6322
|
-
if (!orderedCodecs || orderedCodecs.length === 0)
|
|
6323
|
-
return sdp;
|
|
6324
|
-
const transceiver = this.transceiverCache.get(trackType);
|
|
6325
|
-
if (!transceiver)
|
|
6326
|
-
return sdp;
|
|
6327
|
-
const index = this.transceiverInitOrder.indexOf(trackType);
|
|
6328
|
-
const mid = extractMid(transceiver, index, sdp);
|
|
6329
|
-
const [codecToPreserve] = orderedCodecs;
|
|
6330
|
-
return preserveCodec(sdp, mid, codecToPreserve);
|
|
6331
|
-
}
|
|
6332
|
-
}
|
|
6333
|
-
|
|
6334
|
-
/**
|
|
6335
|
-
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
6336
|
-
* media streams from the SFU.
|
|
6337
|
-
*
|
|
6338
|
-
* @internal
|
|
6339
|
-
*/
|
|
6340
|
-
class Subscriber {
|
|
6341
|
-
/**
|
|
6342
|
-
* Constructs a new `Subscriber` instance.
|
|
6343
|
-
*
|
|
6344
|
-
* @param sfuClient the SFU client to use.
|
|
6345
|
-
* @param dispatcher the dispatcher to use.
|
|
6346
|
-
* @param state the state of the call.
|
|
6347
|
-
* @param connectionConfig the connection configuration to use.
|
|
6348
|
-
* @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
|
|
6349
|
-
* @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
|
|
6350
|
-
* @param logTag a tag to use for logging.
|
|
6351
|
-
*/
|
|
6352
|
-
constructor({ sfuClient, dispatcher, state, connectionConfig, onUnrecoverableError, logTag, }) {
|
|
6353
|
-
this.isIceRestarting = false;
|
|
6354
|
-
/**
|
|
6355
|
-
* Creates a new `RTCPeerConnection` instance with the given configuration.
|
|
6356
|
-
*
|
|
6357
|
-
* @param connectionConfig the connection configuration to use.
|
|
6358
|
-
*/
|
|
6359
|
-
this.createPeerConnection = (connectionConfig) => {
|
|
6360
|
-
const pc = new RTCPeerConnection(connectionConfig);
|
|
6361
|
-
pc.addEventListener('icecandidate', this.onIceCandidate);
|
|
6362
|
-
pc.addEventListener('track', this.handleOnTrack);
|
|
6363
|
-
pc.addEventListener('icecandidateerror', this.onIceCandidateError);
|
|
6364
|
-
pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
6365
|
-
pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
|
|
6366
|
-
return pc;
|
|
5844
|
+
return tracks;
|
|
6367
5845
|
};
|
|
6368
5846
|
/**
|
|
6369
|
-
*
|
|
6370
|
-
|
|
6371
|
-
this.close = () => {
|
|
6372
|
-
this.detachEventHandlers();
|
|
6373
|
-
this.pc.close();
|
|
6374
|
-
};
|
|
6375
|
-
/**
|
|
6376
|
-
* Detaches the event handlers from the `RTCPeerConnection`.
|
|
6377
|
-
* This is useful when we want to replace the `RTCPeerConnection`
|
|
6378
|
-
* 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.
|
|
6379
5849
|
*/
|
|
6380
|
-
this.
|
|
6381
|
-
|
|
6382
|
-
this.
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
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;
|
|
6388
5860
|
};
|
|
6389
5861
|
/**
|
|
6390
|
-
* Returns
|
|
6391
|
-
*
|
|
6392
|
-
*
|
|
6393
|
-
*/
|
|
6394
|
-
this.
|
|
6395
|
-
|
|
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;
|
|
6396
5876
|
};
|
|
6397
5877
|
/**
|
|
6398
|
-
*
|
|
6399
|
-
*
|
|
6400
|
-
* @param sfuClient the SFU client to use.
|
|
5878
|
+
* Converts the given transceiver to a `TrackInfo` object.
|
|
6401
5879
|
*/
|
|
6402
|
-
this.
|
|
6403
|
-
|
|
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
|
+
};
|
|
6404
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);
|
|
6405
5947
|
/**
|
|
6406
5948
|
* Restarts the ICE connection and renegotiates with the SFU.
|
|
6407
5949
|
*/
|
|
@@ -6464,7 +6006,15 @@ class Subscriber {
|
|
|
6464
6006
|
this.logger('error', `Unknown track type: ${rawTrackType}`);
|
|
6465
6007
|
return;
|
|
6466
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
|
|
6467
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
|
|
6468
6018
|
if (previousStream) {
|
|
6469
6019
|
this.logger('info', `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
6470
6020
|
previousStream.getTracks().forEach((t) => {
|
|
@@ -6472,24 +6022,6 @@ class Subscriber {
|
|
|
6472
6022
|
previousStream.removeTrack(t);
|
|
6473
6023
|
});
|
|
6474
6024
|
}
|
|
6475
|
-
this.state.updateParticipant(participantToUpdate.sessionId, {
|
|
6476
|
-
[streamKindProp]: primaryStream,
|
|
6477
|
-
});
|
|
6478
|
-
};
|
|
6479
|
-
this.onIceCandidate = (e) => {
|
|
6480
|
-
const { candidate } = e;
|
|
6481
|
-
if (!candidate) {
|
|
6482
|
-
this.logger('debug', 'null ice candidate');
|
|
6483
|
-
return;
|
|
6484
|
-
}
|
|
6485
|
-
this.sfuClient
|
|
6486
|
-
.iceTrickle({
|
|
6487
|
-
iceCandidate: getIceCandidate(candidate),
|
|
6488
|
-
peerType: PeerType.SUBSCRIBER,
|
|
6489
|
-
})
|
|
6490
|
-
.catch((err) => {
|
|
6491
|
-
this.logger('warn', `ICETrickle failed`, err);
|
|
6492
|
-
});
|
|
6493
6025
|
};
|
|
6494
6026
|
this.negotiate = async (subscriberOffer) => {
|
|
6495
6027
|
this.logger('info', `Received subscriberOffer`, subscriberOffer);
|
|
@@ -6497,15 +6029,7 @@ class Subscriber {
|
|
|
6497
6029
|
type: 'offer',
|
|
6498
6030
|
sdp: subscriberOffer.sdp,
|
|
6499
6031
|
});
|
|
6500
|
-
this.
|
|
6501
|
-
try {
|
|
6502
|
-
const iceCandidate = JSON.parse(candidate.iceCandidate);
|
|
6503
|
-
await this.pc.addIceCandidate(iceCandidate);
|
|
6504
|
-
}
|
|
6505
|
-
catch (e) {
|
|
6506
|
-
this.logger('warn', `ICE candidate error`, [e, candidate]);
|
|
6507
|
-
}
|
|
6508
|
-
});
|
|
6032
|
+
this.addTrickledIceCandidates();
|
|
6509
6033
|
const answer = await this.pc.createAnswer();
|
|
6510
6034
|
await this.pc.setLocalDescription(answer);
|
|
6511
6035
|
await this.sfuClient.sendAnswer({
|
|
@@ -6514,56 +6038,21 @@ class Subscriber {
|
|
|
6514
6038
|
});
|
|
6515
6039
|
this.isIceRestarting = false;
|
|
6516
6040
|
};
|
|
6517
|
-
this.
|
|
6518
|
-
|
|
6519
|
-
this.
|
|
6520
|
-
if (this.state.callingState === CallingState.RECONNECTING)
|
|
6521
|
-
return;
|
|
6522
|
-
// do nothing when ICE is restarting
|
|
6523
|
-
if (this.isIceRestarting)
|
|
6524
|
-
return;
|
|
6525
|
-
if (state === 'failed' || state === 'disconnected') {
|
|
6526
|
-
this.logger('debug', `Attempting to restart ICE`);
|
|
6527
|
-
this.restartIce().catch((e) => {
|
|
6528
|
-
this.logger('error', `ICE restart failed`, e);
|
|
6529
|
-
this.onUnrecoverableError?.();
|
|
6530
|
-
});
|
|
6531
|
-
}
|
|
6532
|
-
};
|
|
6533
|
-
this.onIceGatheringStateChange = () => {
|
|
6534
|
-
this.logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
|
|
6535
|
-
};
|
|
6536
|
-
this.onIceCandidateError = (e) => {
|
|
6537
|
-
const errorMessage = e instanceof RTCPeerConnectionIceErrorEvent &&
|
|
6538
|
-
`${e.errorCode}: ${e.errorText}`;
|
|
6539
|
-
const iceState = this.pc.iceConnectionState;
|
|
6540
|
-
const logLevel = iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
|
|
6541
|
-
this.logger(logLevel, `ICE Candidate error`, errorMessage);
|
|
6542
|
-
};
|
|
6543
|
-
this.logger = getLogger(['Subscriber', logTag]);
|
|
6544
|
-
this.sfuClient = sfuClient;
|
|
6545
|
-
this.state = state;
|
|
6546
|
-
this.onUnrecoverableError = onUnrecoverableError;
|
|
6547
|
-
this.pc = this.createPeerConnection(connectionConfig);
|
|
6548
|
-
const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
|
|
6549
|
-
this.unregisterOnSubscriberOffer = dispatcher.on('subscriberOffer', (subscriberOffer) => {
|
|
6550
|
-
withoutConcurrency(subscriberOfferConcurrencyTag, () => {
|
|
6551
|
-
return this.negotiate(subscriberOffer);
|
|
6552
|
-
}).catch((err) => {
|
|
6041
|
+
this.pc.addEventListener('track', this.handleOnTrack);
|
|
6042
|
+
this.on('subscriberOffer', async (subscriberOffer) => {
|
|
6043
|
+
return this.negotiate(subscriberOffer).catch((err) => {
|
|
6553
6044
|
this.logger('error', `Negotiation failed.`, err);
|
|
6554
6045
|
});
|
|
6555
6046
|
});
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
});
|
|
6566
|
-
});
|
|
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);
|
|
6567
6056
|
}
|
|
6568
6057
|
}
|
|
6569
6058
|
|
|
@@ -6595,6 +6084,16 @@ const createWebSocketSignalChannel = (opts) => {
|
|
|
6595
6084
|
return ws;
|
|
6596
6085
|
};
|
|
6597
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
|
+
|
|
6598
6097
|
/**
|
|
6599
6098
|
* Saving a long-lived reference to a promise that can reject can be unsafe,
|
|
6600
6099
|
* since rejecting the promise causes an unhandled rejection error (even if the
|
|
@@ -6879,6 +6378,7 @@ class StreamSfuClient {
|
|
|
6879
6378
|
clearTimeout(this.migrateAwayTimeout);
|
|
6880
6379
|
this.abortController.abort();
|
|
6881
6380
|
this.migrationTask?.resolve();
|
|
6381
|
+
this.iceTrickleBuffer.dispose();
|
|
6882
6382
|
};
|
|
6883
6383
|
this.leaveAndClose = async (reason) => {
|
|
6884
6384
|
await this.joinTask;
|
|
@@ -6911,13 +6411,9 @@ class StreamSfuClient {
|
|
|
6911
6411
|
await this.joinTask;
|
|
6912
6412
|
return retryable(() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }), this.abortController.signal);
|
|
6913
6413
|
};
|
|
6914
|
-
this.
|
|
6915
|
-
await this.joinTask;
|
|
6916
|
-
return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
|
|
6917
|
-
};
|
|
6918
|
-
this.updateMuteStates = async (data) => {
|
|
6414
|
+
this.updateMuteStates = async (muteStates) => {
|
|
6919
6415
|
await this.joinTask;
|
|
6920
|
-
return retryable(() => this.rpc.updateMuteStates({
|
|
6416
|
+
return retryable(() => this.rpc.updateMuteStates({ muteStates, sessionId: this.sessionId }), this.abortController.signal);
|
|
6921
6417
|
};
|
|
6922
6418
|
this.sendStats = async (stats) => {
|
|
6923
6419
|
await this.joinTask;
|
|
@@ -7097,16 +6593,6 @@ StreamSfuClient.ERROR_CONNECTION_UNHEALTHY = 4001;
|
|
|
7097
6593
|
*/
|
|
7098
6594
|
StreamSfuClient.DISPOSE_OLD_SOCKET = 4002;
|
|
7099
6595
|
|
|
7100
|
-
const toRtcConfiguration = (config) => {
|
|
7101
|
-
return {
|
|
7102
|
-
iceServers: config.map((ice) => ({
|
|
7103
|
-
urls: ice.urls,
|
|
7104
|
-
username: ice.username,
|
|
7105
|
-
credential: ice.password,
|
|
7106
|
-
})),
|
|
7107
|
-
};
|
|
7108
|
-
};
|
|
7109
|
-
|
|
7110
6596
|
/**
|
|
7111
6597
|
* Event handler that watched the delivery of `call.accepted`.
|
|
7112
6598
|
* Once the event is received, the call is joined.
|
|
@@ -7325,6 +6811,21 @@ const handleRemoteSoftMute = (call) => {
|
|
|
7325
6811
|
});
|
|
7326
6812
|
};
|
|
7327
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
|
+
|
|
7328
6829
|
/**
|
|
7329
6830
|
* An event responder which handles the `participantJoined` event.
|
|
7330
6831
|
*/
|
|
@@ -7390,7 +6891,7 @@ const watchTrackPublished = (state) => {
|
|
|
7390
6891
|
}
|
|
7391
6892
|
else {
|
|
7392
6893
|
state.updateParticipant(sessionId, (p) => ({
|
|
7393
|
-
publishedTracks: [...p.publishedTracks, type
|
|
6894
|
+
publishedTracks: pushToIfMissing([...p.publishedTracks], type),
|
|
7394
6895
|
}));
|
|
7395
6896
|
}
|
|
7396
6897
|
};
|
|
@@ -7415,7 +6916,6 @@ const watchTrackUnpublished = (state) => {
|
|
|
7415
6916
|
}
|
|
7416
6917
|
};
|
|
7417
6918
|
};
|
|
7418
|
-
const unique = (v, i, arr) => arr.indexOf(v) === i;
|
|
7419
6919
|
/**
|
|
7420
6920
|
* Reconciles orphaned tracks (if any) for the given participant.
|
|
7421
6921
|
*
|
|
@@ -7565,6 +7065,38 @@ const getSdkVersion = (sdk) => {
|
|
|
7565
7065
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
7566
7066
|
};
|
|
7567
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
|
+
|
|
7568
7100
|
/**
|
|
7569
7101
|
* Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
|
|
7570
7102
|
*/
|
|
@@ -7581,12 +7113,12 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
|
|
|
7581
7113
|
return undefined;
|
|
7582
7114
|
}
|
|
7583
7115
|
};
|
|
7584
|
-
const getStatsForStream = async (kind,
|
|
7116
|
+
const getStatsForStream = async (kind, tracks) => {
|
|
7585
7117
|
const pc = kind === 'subscriber' ? subscriber : publisher;
|
|
7586
7118
|
if (!pc)
|
|
7587
7119
|
return [];
|
|
7588
7120
|
const statsForStream = [];
|
|
7589
|
-
for (
|
|
7121
|
+
for (const track of tracks) {
|
|
7590
7122
|
const report = await pc.getStats(track);
|
|
7591
7123
|
const stats = transform(report, {
|
|
7592
7124
|
// @ts-ignore
|
|
@@ -7611,26 +7143,24 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
|
|
|
7611
7143
|
*/
|
|
7612
7144
|
const run = async () => {
|
|
7613
7145
|
const participantStats = {};
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
for (
|
|
7146
|
+
if (sessionIdsToTrack.size > 0) {
|
|
7147
|
+
const sessionIds = new Set(sessionIdsToTrack);
|
|
7148
|
+
for (const participant of state.participants) {
|
|
7617
7149
|
if (!sessionIds.has(participant.sessionId))
|
|
7618
7150
|
continue;
|
|
7619
|
-
const
|
|
7620
|
-
|
|
7621
|
-
: 'subscriber';
|
|
7151
|
+
const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant;
|
|
7152
|
+
const kind = isLocalParticipant ? 'publisher' : 'subscriber';
|
|
7622
7153
|
try {
|
|
7623
|
-
const
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
});
|
|
7154
|
+
const tracks = isLocalParticipant
|
|
7155
|
+
? publisher?.getPublishedTracks() || []
|
|
7156
|
+
: [
|
|
7157
|
+
...(videoStream?.getVideoTracks() || []),
|
|
7158
|
+
...(audioStream?.getAudioTracks() || []),
|
|
7159
|
+
];
|
|
7160
|
+
participantStats[sessionId] = await getStatsForStream(kind, tracks);
|
|
7631
7161
|
}
|
|
7632
7162
|
catch (e) {
|
|
7633
|
-
logger('
|
|
7163
|
+
logger('warn', `Failed to collect ${kind} stats for ${userId}`, e);
|
|
7634
7164
|
}
|
|
7635
7165
|
}
|
|
7636
7166
|
}
|
|
@@ -7640,6 +7170,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
|
|
|
7640
7170
|
.then((report) => transform(report, {
|
|
7641
7171
|
kind: 'subscriber',
|
|
7642
7172
|
trackKind: 'video',
|
|
7173
|
+
publisher,
|
|
7643
7174
|
}))
|
|
7644
7175
|
.then(aggregate),
|
|
7645
7176
|
publisher
|
|
@@ -7648,6 +7179,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
|
|
|
7648
7179
|
.then((report) => transform(report, {
|
|
7649
7180
|
kind: 'publisher',
|
|
7650
7181
|
trackKind: 'video',
|
|
7182
|
+
publisher,
|
|
7651
7183
|
}))
|
|
7652
7184
|
.then(aggregate)
|
|
7653
7185
|
: getEmptyStats(),
|
|
@@ -7696,7 +7228,7 @@ const createStatsReporter = ({ subscriber, publisher, state, datacenter, polling
|
|
|
7696
7228
|
* @param opts the transform options.
|
|
7697
7229
|
*/
|
|
7698
7230
|
const transform = (report, opts) => {
|
|
7699
|
-
const { trackKind, kind } = opts;
|
|
7231
|
+
const { trackKind, kind, publisher } = opts;
|
|
7700
7232
|
const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp';
|
|
7701
7233
|
const stats = flatten(report);
|
|
7702
7234
|
const streams = stats
|
|
@@ -7712,6 +7244,16 @@ const transform = (report, opts) => {
|
|
|
7712
7244
|
s.id === transport.selectedCandidatePairId);
|
|
7713
7245
|
roundTripTime = candidatePair?.currentRoundTripTime;
|
|
7714
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
|
+
}
|
|
7715
7257
|
return {
|
|
7716
7258
|
bytesSent: rtcStreamStats.bytesSent,
|
|
7717
7259
|
bytesReceived: rtcStreamStats.bytesReceived,
|
|
@@ -7722,10 +7264,12 @@ const transform = (report, opts) => {
|
|
|
7722
7264
|
framesPerSecond: rtcStreamStats.framesPerSecond,
|
|
7723
7265
|
jitter: rtcStreamStats.jitter,
|
|
7724
7266
|
kind: rtcStreamStats.kind,
|
|
7267
|
+
mediaSourceId: rtcStreamStats.mediaSourceId,
|
|
7725
7268
|
// @ts-ignore: available in Chrome only, TS doesn't recognize this
|
|
7726
7269
|
qualityLimitationReason: rtcStreamStats.qualityLimitationReason,
|
|
7727
7270
|
rid: rtcStreamStats.rid,
|
|
7728
7271
|
ssrc: rtcStreamStats.ssrc,
|
|
7272
|
+
trackType,
|
|
7729
7273
|
};
|
|
7730
7274
|
});
|
|
7731
7275
|
return {
|
|
@@ -7746,6 +7290,7 @@ const getEmptyStats = (stats) => {
|
|
|
7746
7290
|
highestFrameHeight: 0,
|
|
7747
7291
|
highestFramesPerSecond: 0,
|
|
7748
7292
|
codec: '',
|
|
7293
|
+
codecPerTrackType: {},
|
|
7749
7294
|
timestamp: Date.now(),
|
|
7750
7295
|
};
|
|
7751
7296
|
};
|
|
@@ -7781,6 +7326,12 @@ const aggregate = (stats) => {
|
|
|
7781
7326
|
report.averageRoundTripTimeInMs = Math.round((report.averageRoundTripTimeInMs / streams.length) * 1000);
|
|
7782
7327
|
// we take the first codec we find, as it should be the same for all streams
|
|
7783
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
|
+
}, {});
|
|
7784
7335
|
}
|
|
7785
7336
|
const qualityLimitationReason = [
|
|
7786
7337
|
qualityLimitationReasons.has('cpu') && 'cpu',
|
|
@@ -7792,7 +7343,135 @@ const aggregate = (stats) => {
|
|
|
7792
7343
|
if (qualityLimitationReason) {
|
|
7793
7344
|
report.qualityLimitationReasons = qualityLimitationReason;
|
|
7794
7345
|
}
|
|
7795
|
-
return report;
|
|
7346
|
+
return report;
|
|
7347
|
+
};
|
|
7348
|
+
|
|
7349
|
+
const version = "1.15.1";
|
|
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
|
+
};
|
|
7796
7475
|
};
|
|
7797
7476
|
|
|
7798
7477
|
class SfuStatsReporter {
|
|
@@ -7828,8 +7507,28 @@ class SfuStatsReporter {
|
|
|
7828
7507
|
});
|
|
7829
7508
|
});
|
|
7830
7509
|
};
|
|
7831
|
-
this.
|
|
7832
|
-
|
|
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
|
+
});
|
|
7833
7532
|
};
|
|
7834
7533
|
this.run = async (telemetryData) => {
|
|
7835
7534
|
const [subscriberStats, publisherStats] = await Promise.all([
|
|
@@ -8397,6 +8096,25 @@ class PermissionsContext {
|
|
|
8397
8096
|
this.hasPermission = (permission) => {
|
|
8398
8097
|
return this.permissions.includes(permission);
|
|
8399
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
|
+
};
|
|
8400
8118
|
/**
|
|
8401
8119
|
* Checks if the current user can request a specific permission
|
|
8402
8120
|
* within the call.
|
|
@@ -8853,6 +8571,14 @@ const disposeOfMediaStream = (stream) => {
|
|
|
8853
8571
|
}
|
|
8854
8572
|
};
|
|
8855
8573
|
|
|
8574
|
+
/**
|
|
8575
|
+
* Checks if the current platform is a mobile device.
|
|
8576
|
+
*
|
|
8577
|
+
* See:
|
|
8578
|
+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
|
8579
|
+
*/
|
|
8580
|
+
const isMobile = () => /Mobi/i.test(navigator.userAgent);
|
|
8581
|
+
|
|
8856
8582
|
class InputMediaDeviceManager {
|
|
8857
8583
|
constructor(call, state, trackType) {
|
|
8858
8584
|
this.call = call;
|
|
@@ -9035,36 +8761,42 @@ class InputMediaDeviceManager {
|
|
|
9035
8761
|
}
|
|
9036
8762
|
});
|
|
9037
8763
|
}
|
|
8764
|
+
publishStream(stream) {
|
|
8765
|
+
return this.call.publish(stream, this.trackType);
|
|
8766
|
+
}
|
|
8767
|
+
stopPublishStream() {
|
|
8768
|
+
return this.call.stopPublish(this.trackType);
|
|
8769
|
+
}
|
|
9038
8770
|
getTracks() {
|
|
9039
8771
|
return this.state.mediaStream?.getTracks() ?? [];
|
|
9040
8772
|
}
|
|
9041
8773
|
async muteStream(stopTracks = true) {
|
|
9042
|
-
|
|
8774
|
+
const mediaStream = this.state.mediaStream;
|
|
8775
|
+
if (!mediaStream)
|
|
9043
8776
|
return;
|
|
9044
8777
|
this.logger('debug', `${stopTracks ? 'Stopping' : 'Disabling'} stream`);
|
|
9045
8778
|
if (this.call.state.callingState === CallingState.JOINED) {
|
|
9046
|
-
await this.stopPublishStream(
|
|
8779
|
+
await this.stopPublishStream();
|
|
9047
8780
|
}
|
|
9048
8781
|
this.muteLocalStream(stopTracks);
|
|
9049
8782
|
const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
|
|
9050
8783
|
if (allEnded) {
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
typeof this.state.mediaStream.release === 'function') {
|
|
8784
|
+
// @ts-expect-error release() is present in react-native-webrtc
|
|
8785
|
+
if (typeof mediaStream.release === 'function') {
|
|
9054
8786
|
// @ts-expect-error called to dispose the stream in RN
|
|
9055
|
-
|
|
8787
|
+
mediaStream.release();
|
|
9056
8788
|
}
|
|
9057
8789
|
this.state.setMediaStream(undefined, undefined);
|
|
9058
8790
|
this.filters.forEach((entry) => entry.stop?.());
|
|
9059
8791
|
}
|
|
9060
8792
|
}
|
|
9061
|
-
|
|
8793
|
+
disableTracks() {
|
|
9062
8794
|
this.getTracks().forEach((track) => {
|
|
9063
8795
|
if (track.enabled)
|
|
9064
8796
|
track.enabled = false;
|
|
9065
8797
|
});
|
|
9066
8798
|
}
|
|
9067
|
-
|
|
8799
|
+
enableTracks() {
|
|
9068
8800
|
this.getTracks().forEach((track) => {
|
|
9069
8801
|
if (!track.enabled)
|
|
9070
8802
|
track.enabled = true;
|
|
@@ -9084,7 +8816,7 @@ class InputMediaDeviceManager {
|
|
|
9084
8816
|
this.stopTracks();
|
|
9085
8817
|
}
|
|
9086
8818
|
else {
|
|
9087
|
-
this.
|
|
8819
|
+
this.disableTracks();
|
|
9088
8820
|
}
|
|
9089
8821
|
}
|
|
9090
8822
|
async unmuteStream() {
|
|
@@ -9094,7 +8826,7 @@ class InputMediaDeviceManager {
|
|
|
9094
8826
|
if (this.state.mediaStream &&
|
|
9095
8827
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
9096
8828
|
stream = this.state.mediaStream;
|
|
9097
|
-
this.
|
|
8829
|
+
this.enableTracks();
|
|
9098
8830
|
}
|
|
9099
8831
|
else {
|
|
9100
8832
|
const defaultConstraints = this.state.defaultConstraints;
|
|
@@ -9188,9 +8920,24 @@ class InputMediaDeviceManager {
|
|
|
9188
8920
|
await this.disable();
|
|
9189
8921
|
}
|
|
9190
8922
|
};
|
|
9191
|
-
|
|
8923
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
8924
|
+
if (!isMobile() || this.trackType !== TrackType.VIDEO)
|
|
8925
|
+
return;
|
|
8926
|
+
this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
|
|
8927
|
+
this.logger('warn', 'Error while notifying track mute state', err);
|
|
8928
|
+
});
|
|
8929
|
+
};
|
|
8930
|
+
stream.getTracks().forEach((track) => {
|
|
8931
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
8932
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
8933
|
+
track.addEventListener('mute', muteHandler);
|
|
8934
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
9192
8935
|
track.addEventListener('ended', handleTrackEnded);
|
|
9193
|
-
this.subscriptions.push(() =>
|
|
8936
|
+
this.subscriptions.push(() => {
|
|
8937
|
+
track.removeEventListener('mute', muteHandler);
|
|
8938
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
8939
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
8940
|
+
});
|
|
9194
8941
|
});
|
|
9195
8942
|
}
|
|
9196
8943
|
}
|
|
@@ -9214,8 +8961,8 @@ class InputMediaDeviceManager {
|
|
|
9214
8961
|
await this.statusChangeSettled();
|
|
9215
8962
|
let isDeviceDisconnected = false;
|
|
9216
8963
|
let isDeviceReplaced = false;
|
|
9217
|
-
const currentDevice = this.
|
|
9218
|
-
const prevDevice = this.
|
|
8964
|
+
const currentDevice = this.findDevice(currentDevices, deviceId);
|
|
8965
|
+
const prevDevice = this.findDevice(prevDevices, deviceId);
|
|
9219
8966
|
if (!currentDevice && prevDevice) {
|
|
9220
8967
|
isDeviceDisconnected = true;
|
|
9221
8968
|
}
|
|
@@ -9245,8 +8992,9 @@ class InputMediaDeviceManager {
|
|
|
9245
8992
|
}
|
|
9246
8993
|
}));
|
|
9247
8994
|
}
|
|
9248
|
-
|
|
9249
|
-
|
|
8995
|
+
findDevice(devices, deviceId) {
|
|
8996
|
+
const kind = this.mediaDeviceKind;
|
|
8997
|
+
return devices.find((d) => d.deviceId === deviceId && d.kind === kind);
|
|
9250
8998
|
}
|
|
9251
8999
|
}
|
|
9252
9000
|
|
|
@@ -9432,14 +9180,6 @@ class CameraManagerState extends InputMediaDeviceManagerState {
|
|
|
9432
9180
|
}
|
|
9433
9181
|
}
|
|
9434
9182
|
|
|
9435
|
-
/**
|
|
9436
|
-
* Checks if the current platform is a mobile device.
|
|
9437
|
-
*
|
|
9438
|
-
* See:
|
|
9439
|
-
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
|
9440
|
-
*/
|
|
9441
|
-
const isMobile = () => /Mobi/i.test(navigator.userAgent);
|
|
9442
|
-
|
|
9443
9183
|
class CameraManager extends InputMediaDeviceManager {
|
|
9444
9184
|
/**
|
|
9445
9185
|
* Constructs a new CameraManager.
|
|
@@ -9510,14 +9250,35 @@ class CameraManager extends InputMediaDeviceManager {
|
|
|
9510
9250
|
}
|
|
9511
9251
|
}
|
|
9512
9252
|
/**
|
|
9513
|
-
*
|
|
9253
|
+
* Applies the video settings to the camera.
|
|
9514
9254
|
*
|
|
9515
|
-
* @
|
|
9516
|
-
* @
|
|
9517
|
-
* @param codec the codec to use for encoding the video.
|
|
9255
|
+
* @param settings the video settings to apply.
|
|
9256
|
+
* @param publish whether to publish the stream after applying the settings.
|
|
9518
9257
|
*/
|
|
9519
|
-
|
|
9520
|
-
this.call.
|
|
9258
|
+
async apply(settings, publish) {
|
|
9259
|
+
const hasPublishedVideo = !!this.call.state.localParticipant?.videoStream;
|
|
9260
|
+
const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
|
|
9261
|
+
if (hasPublishedVideo || !hasPermission)
|
|
9262
|
+
return;
|
|
9263
|
+
// Wait for any in progress camera operation
|
|
9264
|
+
await this.statusChangeSettled();
|
|
9265
|
+
const { target_resolution, camera_facing, camera_default_on } = settings;
|
|
9266
|
+
await this.selectTargetResolution(target_resolution);
|
|
9267
|
+
// Set camera direction if it's not yet set
|
|
9268
|
+
if (!this.state.direction && !this.state.selectedDevice) {
|
|
9269
|
+
this.state.setDirection(camera_facing === 'front' ? 'front' : 'back');
|
|
9270
|
+
}
|
|
9271
|
+
if (!publish)
|
|
9272
|
+
return;
|
|
9273
|
+
const { mediaStream } = this.state;
|
|
9274
|
+
if (this.enabled && mediaStream) {
|
|
9275
|
+
// The camera is already enabled (e.g. lobby screen). Publish the stream
|
|
9276
|
+
await this.publishStream(mediaStream);
|
|
9277
|
+
}
|
|
9278
|
+
else if (this.state.status === undefined && camera_default_on) {
|
|
9279
|
+
// Start camera if backend config specifies, and there is no local setting
|
|
9280
|
+
await this.enable();
|
|
9281
|
+
}
|
|
9521
9282
|
}
|
|
9522
9283
|
getDevices() {
|
|
9523
9284
|
return getVideoDevices();
|
|
@@ -9535,12 +9296,6 @@ class CameraManager extends InputMediaDeviceManager {
|
|
|
9535
9296
|
}
|
|
9536
9297
|
return getVideoStream(constraints);
|
|
9537
9298
|
}
|
|
9538
|
-
publishStream(stream) {
|
|
9539
|
-
return this.call.publishVideoStream(stream);
|
|
9540
|
-
}
|
|
9541
|
-
stopPublishStream(stopTracks) {
|
|
9542
|
-
return this.call.stopPublish(TrackType.VIDEO, stopTracks);
|
|
9543
|
-
}
|
|
9544
9299
|
}
|
|
9545
9300
|
|
|
9546
9301
|
class MicrophoneManagerState extends InputMediaDeviceManagerState {
|
|
@@ -9868,18 +9623,37 @@ class MicrophoneManager extends InputMediaDeviceManager {
|
|
|
9868
9623
|
this.speakingWhileMutedNotificationEnabled = false;
|
|
9869
9624
|
await this.stopSpeakingWhileMutedDetection();
|
|
9870
9625
|
}
|
|
9626
|
+
/**
|
|
9627
|
+
* Applies the audio settings to the microphone.
|
|
9628
|
+
* @param settings the audio settings to apply.
|
|
9629
|
+
* @param publish whether to publish the stream after applying the settings.
|
|
9630
|
+
*/
|
|
9631
|
+
async apply(settings, publish) {
|
|
9632
|
+
if (!publish)
|
|
9633
|
+
return;
|
|
9634
|
+
const hasPublishedAudio = !!this.call.state.localParticipant?.audioStream;
|
|
9635
|
+
const hasPermission = this.call.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO);
|
|
9636
|
+
if (hasPublishedAudio || !hasPermission)
|
|
9637
|
+
return;
|
|
9638
|
+
// Wait for any in progress mic operation
|
|
9639
|
+
await this.statusChangeSettled();
|
|
9640
|
+
// Publish media stream that was set before we joined
|
|
9641
|
+
const { mediaStream } = this.state;
|
|
9642
|
+
if (this.enabled && mediaStream) {
|
|
9643
|
+
// The mic is already enabled (e.g. lobby screen). Publish the stream
|
|
9644
|
+
await this.publishStream(mediaStream);
|
|
9645
|
+
}
|
|
9646
|
+
else if (this.state.status === undefined && settings.mic_default_on) {
|
|
9647
|
+
// Start mic if backend config specifies, and there is no local setting
|
|
9648
|
+
await this.enable();
|
|
9649
|
+
}
|
|
9650
|
+
}
|
|
9871
9651
|
getDevices() {
|
|
9872
9652
|
return getAudioDevices();
|
|
9873
9653
|
}
|
|
9874
9654
|
getStream(constraints) {
|
|
9875
9655
|
return getAudioStream(constraints);
|
|
9876
9656
|
}
|
|
9877
|
-
publishStream(stream) {
|
|
9878
|
-
return this.call.publishAudioStream(stream);
|
|
9879
|
-
}
|
|
9880
|
-
stopPublishStream(stopTracks) {
|
|
9881
|
-
return this.call.stopPublish(TrackType.AUDIO, stopTracks);
|
|
9882
|
-
}
|
|
9883
9657
|
async startSpeakingWhileMutedDetection(deviceId) {
|
|
9884
9658
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
9885
9659
|
await this.stopSpeakingWhileMutedDetection();
|
|
@@ -9999,7 +9773,7 @@ class ScreenShareManager extends InputMediaDeviceManager {
|
|
|
9999
9773
|
async disableScreenShareAudio() {
|
|
10000
9774
|
this.state.setAudioEnabled(false);
|
|
10001
9775
|
if (this.call.publisher?.isPublishing(TrackType.SCREEN_SHARE_AUDIO)) {
|
|
10002
|
-
await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO
|
|
9776
|
+
await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO);
|
|
10003
9777
|
}
|
|
10004
9778
|
}
|
|
10005
9779
|
/**
|
|
@@ -10025,12 +9799,8 @@ class ScreenShareManager extends InputMediaDeviceManager {
|
|
|
10025
9799
|
}
|
|
10026
9800
|
return getScreenShareStream(constraints);
|
|
10027
9801
|
}
|
|
10028
|
-
|
|
10029
|
-
return this.call.
|
|
10030
|
-
}
|
|
10031
|
-
async stopPublishStream(stopTracks) {
|
|
10032
|
-
await this.call.stopPublish(TrackType.SCREEN_SHARE, stopTracks);
|
|
10033
|
-
await this.call.stopPublish(TrackType.SCREEN_SHARE_AUDIO, stopTracks);
|
|
9802
|
+
async stopPublishStream() {
|
|
9803
|
+
return this.call.stopPublish(TrackType.SCREEN_SHARE, TrackType.SCREEN_SHARE_AUDIO);
|
|
10034
9804
|
}
|
|
10035
9805
|
/**
|
|
10036
9806
|
* Overrides the default `select` method to throw an error.
|
|
@@ -10240,6 +10010,112 @@ class Call {
|
|
|
10240
10010
|
*/
|
|
10241
10011
|
this.leaveCallHooks = new Set();
|
|
10242
10012
|
this.streamClientEventHandlers = new Map();
|
|
10013
|
+
this.setup = async () => {
|
|
10014
|
+
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
10015
|
+
if (this.initialized)
|
|
10016
|
+
return;
|
|
10017
|
+
this.leaveCallHooks.add(this.on('all', (event) => {
|
|
10018
|
+
// update state with the latest event data
|
|
10019
|
+
this.state.updateFromEvent(event);
|
|
10020
|
+
}));
|
|
10021
|
+
this.leaveCallHooks.add(this.on('changePublishOptions', (event) => {
|
|
10022
|
+
this.currentPublishOptions = event.publishOptions;
|
|
10023
|
+
}));
|
|
10024
|
+
this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
|
|
10025
|
+
this.registerEffects();
|
|
10026
|
+
this.registerReconnectHandlers();
|
|
10027
|
+
if (this.state.callingState === CallingState.LEFT) {
|
|
10028
|
+
this.state.setCallingState(CallingState.IDLE);
|
|
10029
|
+
}
|
|
10030
|
+
this.initialized = true;
|
|
10031
|
+
});
|
|
10032
|
+
};
|
|
10033
|
+
this.registerEffects = () => {
|
|
10034
|
+
this.leaveCallHooks.add(
|
|
10035
|
+
// handles updating the permissions context when the settings change.
|
|
10036
|
+
createSubscription(this.state.settings$, (settings) => {
|
|
10037
|
+
if (!settings)
|
|
10038
|
+
return;
|
|
10039
|
+
this.permissionsContext.setCallSettings(settings);
|
|
10040
|
+
}));
|
|
10041
|
+
this.leaveCallHooks.add(
|
|
10042
|
+
// handle the case when the user permissions are modified.
|
|
10043
|
+
createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
|
|
10044
|
+
this.leaveCallHooks.add(
|
|
10045
|
+
// handles the case when the user is blocked by the call owner.
|
|
10046
|
+
createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
|
|
10047
|
+
if (!blockedUserIds || blockedUserIds.length === 0)
|
|
10048
|
+
return;
|
|
10049
|
+
const currentUserId = this.currentUserId;
|
|
10050
|
+
if (currentUserId && blockedUserIds.includes(currentUserId)) {
|
|
10051
|
+
this.logger('info', 'Leaving call because of being blocked');
|
|
10052
|
+
await this.leave({ reason: 'user blocked' }).catch((err) => {
|
|
10053
|
+
this.logger('error', 'Error leaving call after being blocked', err);
|
|
10054
|
+
});
|
|
10055
|
+
}
|
|
10056
|
+
}));
|
|
10057
|
+
this.leaveCallHooks.add(
|
|
10058
|
+
// cancel auto-drop when call is
|
|
10059
|
+
createSubscription(this.state.session$, (session) => {
|
|
10060
|
+
if (!this.ringing)
|
|
10061
|
+
return;
|
|
10062
|
+
const receiverId = this.clientStore.connectedUser?.id;
|
|
10063
|
+
if (!receiverId)
|
|
10064
|
+
return;
|
|
10065
|
+
const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
|
|
10066
|
+
const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
|
|
10067
|
+
if (isAcceptedByMe || isRejectedByMe) {
|
|
10068
|
+
this.cancelAutoDrop();
|
|
10069
|
+
}
|
|
10070
|
+
}));
|
|
10071
|
+
this.leaveCallHooks.add(
|
|
10072
|
+
// "ringing" mode effects and event handlers
|
|
10073
|
+
createSubscription(this.ringingSubject, (isRinging) => {
|
|
10074
|
+
if (!isRinging)
|
|
10075
|
+
return;
|
|
10076
|
+
const callSession = this.state.session;
|
|
10077
|
+
const receiver_id = this.clientStore.connectedUser?.id;
|
|
10078
|
+
const ended_at = callSession?.ended_at;
|
|
10079
|
+
const created_by_id = this.state.createdBy?.id;
|
|
10080
|
+
const rejected_by = callSession?.rejected_by;
|
|
10081
|
+
const accepted_by = callSession?.accepted_by;
|
|
10082
|
+
let leaveCallIdle = false;
|
|
10083
|
+
if (ended_at) {
|
|
10084
|
+
// call was ended before it was accepted or rejected so we should leave it to idle
|
|
10085
|
+
leaveCallIdle = true;
|
|
10086
|
+
}
|
|
10087
|
+
else if (created_by_id && rejected_by) {
|
|
10088
|
+
if (rejected_by[created_by_id]) {
|
|
10089
|
+
// call was cancelled by the caller
|
|
10090
|
+
leaveCallIdle = true;
|
|
10091
|
+
}
|
|
10092
|
+
}
|
|
10093
|
+
else if (receiver_id && rejected_by) {
|
|
10094
|
+
if (rejected_by[receiver_id]) {
|
|
10095
|
+
// call was rejected by the receiver in some other device
|
|
10096
|
+
leaveCallIdle = true;
|
|
10097
|
+
}
|
|
10098
|
+
}
|
|
10099
|
+
else if (receiver_id && accepted_by) {
|
|
10100
|
+
if (accepted_by[receiver_id]) {
|
|
10101
|
+
// call was accepted by the receiver in some other device
|
|
10102
|
+
leaveCallIdle = true;
|
|
10103
|
+
}
|
|
10104
|
+
}
|
|
10105
|
+
if (leaveCallIdle) {
|
|
10106
|
+
if (this.state.callingState !== CallingState.IDLE) {
|
|
10107
|
+
this.state.setCallingState(CallingState.IDLE);
|
|
10108
|
+
}
|
|
10109
|
+
}
|
|
10110
|
+
else {
|
|
10111
|
+
if (this.state.callingState === CallingState.IDLE) {
|
|
10112
|
+
this.state.setCallingState(CallingState.RINGING);
|
|
10113
|
+
}
|
|
10114
|
+
this.scheduleAutoDrop();
|
|
10115
|
+
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
10116
|
+
}
|
|
10117
|
+
}));
|
|
10118
|
+
};
|
|
10243
10119
|
this.handleOwnCapabilitiesUpdated = async (ownCapabilities) => {
|
|
10244
10120
|
// update the permission context.
|
|
10245
10121
|
this.permissionsContext.setPermissions(ownCapabilities);
|
|
@@ -10352,9 +10228,9 @@ class Call {
|
|
|
10352
10228
|
this.statsReporter = undefined;
|
|
10353
10229
|
this.sfuStatsReporter?.stop();
|
|
10354
10230
|
this.sfuStatsReporter = undefined;
|
|
10355
|
-
this.subscriber?.
|
|
10231
|
+
this.subscriber?.dispose();
|
|
10356
10232
|
this.subscriber = undefined;
|
|
10357
|
-
this.publisher?.
|
|
10233
|
+
this.publisher?.dispose();
|
|
10358
10234
|
this.publisher = undefined;
|
|
10359
10235
|
await this.sfuClient?.leaveAndClose(reason);
|
|
10360
10236
|
this.sfuClient = undefined;
|
|
@@ -10392,7 +10268,8 @@ class Call {
|
|
|
10392
10268
|
// call.ring event excludes the call creator in the members list
|
|
10393
10269
|
// as the creator does not get the ring event
|
|
10394
10270
|
// so update the member list accordingly
|
|
10395
|
-
const
|
|
10271
|
+
const { created_by, settings } = event.call;
|
|
10272
|
+
const creator = this.state.members.find((m) => m.user.id === created_by.id);
|
|
10396
10273
|
if (!creator) {
|
|
10397
10274
|
this.state.setMembers(event.members);
|
|
10398
10275
|
}
|
|
@@ -10407,7 +10284,7 @@ class Call {
|
|
|
10407
10284
|
// const calls = useCalls().filter((c) => c.ringing);
|
|
10408
10285
|
const calls = this.clientStore.calls.filter((c) => c.cid !== this.cid);
|
|
10409
10286
|
this.clientStore.setCalls([this, ...calls]);
|
|
10410
|
-
await this.applyDeviceConfig(false);
|
|
10287
|
+
await this.applyDeviceConfig(settings, false);
|
|
10411
10288
|
};
|
|
10412
10289
|
/**
|
|
10413
10290
|
* Loads the information about the call.
|
|
@@ -10430,7 +10307,7 @@ class Call {
|
|
|
10430
10307
|
this.watching = true;
|
|
10431
10308
|
this.clientStore.registerCall(this);
|
|
10432
10309
|
}
|
|
10433
|
-
await this.applyDeviceConfig(false);
|
|
10310
|
+
await this.applyDeviceConfig(response.call.settings, false);
|
|
10434
10311
|
return response;
|
|
10435
10312
|
};
|
|
10436
10313
|
/**
|
|
@@ -10452,7 +10329,7 @@ class Call {
|
|
|
10452
10329
|
this.watching = true;
|
|
10453
10330
|
this.clientStore.registerCall(this);
|
|
10454
10331
|
}
|
|
10455
|
-
await this.applyDeviceConfig(false);
|
|
10332
|
+
await this.applyDeviceConfig(response.call.settings, false);
|
|
10456
10333
|
return response;
|
|
10457
10334
|
};
|
|
10458
10335
|
/**
|
|
@@ -10554,19 +10431,32 @@ class Call {
|
|
|
10554
10431
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
10555
10432
|
if (previousSfuClient !== sfuClient) {
|
|
10556
10433
|
// prepare a generic SDP and send it to the SFU.
|
|
10557
|
-
//
|
|
10434
|
+
// these are throw-away SDPs that the SFU will use to determine
|
|
10558
10435
|
// the capabilities of the client (codec support, etc.)
|
|
10559
|
-
const
|
|
10560
|
-
|
|
10436
|
+
const [subscriberSdp, publisherSdp] = await Promise.all([
|
|
10437
|
+
getGenericSdp('recvonly'),
|
|
10438
|
+
getGenericSdp('sendonly'),
|
|
10439
|
+
]);
|
|
10440
|
+
const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
10441
|
+
const reconnectDetails = isReconnecting
|
|
10561
10442
|
? this.getReconnectDetails(data?.migrating_from, previousSessionId)
|
|
10562
10443
|
: undefined;
|
|
10563
|
-
const
|
|
10564
|
-
|
|
10565
|
-
|
|
10444
|
+
const preferredPublishOptions = !isReconnecting
|
|
10445
|
+
? this.getPreferredPublishOptions()
|
|
10446
|
+
: this.currentPublishOptions || [];
|
|
10447
|
+
const preferredSubscribeOptions = !isReconnecting
|
|
10448
|
+
? this.getPreferredSubscribeOptions()
|
|
10449
|
+
: [];
|
|
10450
|
+
const { callState, fastReconnectDeadlineSeconds, publishOptions } = await sfuClient.join({
|
|
10451
|
+
subscriberSdp,
|
|
10452
|
+
publisherSdp,
|
|
10566
10453
|
clientDetails,
|
|
10567
10454
|
fastReconnect: performingFastReconnect,
|
|
10568
10455
|
reconnectDetails,
|
|
10456
|
+
preferredPublishOptions,
|
|
10457
|
+
preferredSubscribeOptions,
|
|
10569
10458
|
});
|
|
10459
|
+
this.currentPublishOptions = publishOptions;
|
|
10570
10460
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
10571
10461
|
if (callState) {
|
|
10572
10462
|
this.state.updateFromSfuCallState(callState, sfuClient.sessionId, reconnectDetails);
|
|
@@ -10591,17 +10481,13 @@ class Call {
|
|
|
10591
10481
|
connectionConfig,
|
|
10592
10482
|
clientDetails,
|
|
10593
10483
|
statsOptions,
|
|
10484
|
+
publishOptions: this.currentPublishOptions || [],
|
|
10594
10485
|
closePreviousInstances: !performingMigration,
|
|
10595
10486
|
});
|
|
10596
10487
|
}
|
|
10597
10488
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
10598
10489
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
10599
|
-
this.sfuStatsReporter?.
|
|
10600
|
-
data: {
|
|
10601
|
-
oneofKind: 'connectionTimeSeconds',
|
|
10602
|
-
connectionTimeSeconds: (Date.now() - connectStartTime) / 1000,
|
|
10603
|
-
},
|
|
10604
|
-
});
|
|
10490
|
+
this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
|
|
10605
10491
|
}
|
|
10606
10492
|
if (performingRejoin) {
|
|
10607
10493
|
const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
|
|
@@ -10612,8 +10498,8 @@ class Call {
|
|
|
10612
10498
|
}
|
|
10613
10499
|
// device settings should be applied only once, we don't have to
|
|
10614
10500
|
// re-apply them on later reconnections or server-side data fetches
|
|
10615
|
-
if (!this.deviceSettingsAppliedOnce) {
|
|
10616
|
-
await this.applyDeviceConfig(true);
|
|
10501
|
+
if (!this.deviceSettingsAppliedOnce && this.state.settings) {
|
|
10502
|
+
await this.applyDeviceConfig(this.state.settings, true);
|
|
10617
10503
|
this.deviceSettingsAppliedOnce = true;
|
|
10618
10504
|
}
|
|
10619
10505
|
// We shouldn't persist the `ring` and `notify` state after joining the call
|
|
@@ -10622,6 +10508,8 @@ class Call {
|
|
|
10622
10508
|
// we will spam the other participants with push notifications and `call.ring` events.
|
|
10623
10509
|
delete this.joinCallData?.ring;
|
|
10624
10510
|
delete this.joinCallData?.notify;
|
|
10511
|
+
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
10512
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
10625
10513
|
this.logger('info', `Joined call ${this.cid}`);
|
|
10626
10514
|
};
|
|
10627
10515
|
/**
|
|
@@ -10631,7 +10519,7 @@ class Call {
|
|
|
10631
10519
|
this.getReconnectDetails = (migratingFromSfuId, previousSessionId) => {
|
|
10632
10520
|
const strategy = this.reconnectStrategy;
|
|
10633
10521
|
const performingRejoin = strategy === WebsocketReconnectStrategy.REJOIN;
|
|
10634
|
-
const announcedTracks = this.publisher?.
|
|
10522
|
+
const announcedTracks = this.publisher?.getAnnouncedTracksForReconnect() || [];
|
|
10635
10523
|
return {
|
|
10636
10524
|
strategy,
|
|
10637
10525
|
announcedTracks,
|
|
@@ -10641,6 +10529,54 @@ class Call {
|
|
|
10641
10529
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
10642
10530
|
};
|
|
10643
10531
|
};
|
|
10532
|
+
/**
|
|
10533
|
+
* Prepares the preferred codec for the call.
|
|
10534
|
+
* This is an experimental client feature and subject to change.
|
|
10535
|
+
* @internal
|
|
10536
|
+
*/
|
|
10537
|
+
this.getPreferredPublishOptions = () => {
|
|
10538
|
+
const { preferredCodec, fmtpLine, preferredBitrate, maxSimulcastLayers } = this.clientPublishOptions || {};
|
|
10539
|
+
if (!preferredCodec && !preferredBitrate && !maxSimulcastLayers)
|
|
10540
|
+
return [];
|
|
10541
|
+
const codec = preferredCodec
|
|
10542
|
+
? Codec.create({ name: preferredCodec.split('/').pop(), fmtp: fmtpLine })
|
|
10543
|
+
: undefined;
|
|
10544
|
+
const preferredPublishOptions = [
|
|
10545
|
+
PublishOption.create({
|
|
10546
|
+
trackType: TrackType.VIDEO,
|
|
10547
|
+
codec,
|
|
10548
|
+
bitrate: preferredBitrate,
|
|
10549
|
+
maxSpatialLayers: maxSimulcastLayers,
|
|
10550
|
+
}),
|
|
10551
|
+
];
|
|
10552
|
+
const screenShareSettings = this.screenShare.getSettings();
|
|
10553
|
+
if (screenShareSettings) {
|
|
10554
|
+
preferredPublishOptions.push(PublishOption.create({
|
|
10555
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10556
|
+
fps: screenShareSettings.maxFramerate,
|
|
10557
|
+
bitrate: screenShareSettings.maxBitrate,
|
|
10558
|
+
}));
|
|
10559
|
+
}
|
|
10560
|
+
return preferredPublishOptions;
|
|
10561
|
+
};
|
|
10562
|
+
/**
|
|
10563
|
+
* Prepares the preferred options for subscribing to tracks.
|
|
10564
|
+
* This is an experimental client feature and subject to change.
|
|
10565
|
+
* @internal
|
|
10566
|
+
*/
|
|
10567
|
+
this.getPreferredSubscribeOptions = () => {
|
|
10568
|
+
const { subscriberCodec, subscriberFmtpLine } = this.clientPublishOptions || {};
|
|
10569
|
+
if (!subscriberCodec || !subscriberFmtpLine)
|
|
10570
|
+
return [];
|
|
10571
|
+
return [
|
|
10572
|
+
SubscribeOption.create({
|
|
10573
|
+
trackType: TrackType.VIDEO,
|
|
10574
|
+
codecs: [
|
|
10575
|
+
{ name: subscriberCodec.split('/').pop(), fmtp: subscriberFmtpLine },
|
|
10576
|
+
],
|
|
10577
|
+
}),
|
|
10578
|
+
];
|
|
10579
|
+
};
|
|
10644
10580
|
/**
|
|
10645
10581
|
* Performs an ICE restart on both the Publisher and Subscriber Peer Connections.
|
|
10646
10582
|
* Uses the provided SFU client to restore the ICE connection.
|
|
@@ -10671,9 +10607,9 @@ class Call {
|
|
|
10671
10607
|
* @internal
|
|
10672
10608
|
*/
|
|
10673
10609
|
this.initPublisherAndSubscriber = (opts) => {
|
|
10674
|
-
const { sfuClient, connectionConfig, clientDetails, statsOptions, closePreviousInstances, } = opts;
|
|
10610
|
+
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, } = opts;
|
|
10675
10611
|
if (closePreviousInstances && this.subscriber) {
|
|
10676
|
-
this.subscriber.
|
|
10612
|
+
this.subscriber.dispose();
|
|
10677
10613
|
}
|
|
10678
10614
|
this.subscriber = new Subscriber({
|
|
10679
10615
|
sfuClient,
|
|
@@ -10692,18 +10628,14 @@ class Call {
|
|
|
10692
10628
|
const isAnonymous = this.streamClient.user?.type === 'anonymous';
|
|
10693
10629
|
if (!isAnonymous) {
|
|
10694
10630
|
if (closePreviousInstances && this.publisher) {
|
|
10695
|
-
this.publisher.
|
|
10631
|
+
this.publisher.dispose();
|
|
10696
10632
|
}
|
|
10697
|
-
const audioSettings = this.state.settings?.audio;
|
|
10698
|
-
const isDtxEnabled = !!audioSettings?.opus_dtx_enabled;
|
|
10699
|
-
const isRedEnabled = !!audioSettings?.redundant_coding_enabled;
|
|
10700
10633
|
this.publisher = new Publisher({
|
|
10701
10634
|
sfuClient,
|
|
10702
10635
|
dispatcher: this.dispatcher,
|
|
10703
10636
|
state: this.state,
|
|
10704
10637
|
connectionConfig,
|
|
10705
|
-
|
|
10706
|
-
isRedEnabled,
|
|
10638
|
+
publishOptions,
|
|
10707
10639
|
logTag: String(this.sfuClientTag),
|
|
10708
10640
|
onUnrecoverableError: () => {
|
|
10709
10641
|
this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
|
|
@@ -10850,47 +10782,31 @@ class Call {
|
|
|
10850
10782
|
* @internal
|
|
10851
10783
|
*/
|
|
10852
10784
|
this.reconnectFast = async () => {
|
|
10853
|
-
|
|
10785
|
+
const reconnectStartTime = Date.now();
|
|
10854
10786
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
10855
10787
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
10856
10788
|
await this.join(this.joinCallData);
|
|
10857
|
-
this.sfuStatsReporter?.
|
|
10858
|
-
data: {
|
|
10859
|
-
oneofKind: 'reconnection',
|
|
10860
|
-
reconnection: {
|
|
10861
|
-
timeSeconds: (Date.now() - reconnectStartTime) / 1000,
|
|
10862
|
-
strategy: WebsocketReconnectStrategy.FAST,
|
|
10863
|
-
},
|
|
10864
|
-
},
|
|
10865
|
-
});
|
|
10789
|
+
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
|
|
10866
10790
|
};
|
|
10867
10791
|
/**
|
|
10868
10792
|
* Initiates the reconnection flow with the "rejoin" strategy.
|
|
10869
10793
|
* @internal
|
|
10870
10794
|
*/
|
|
10871
10795
|
this.reconnectRejoin = async () => {
|
|
10872
|
-
|
|
10796
|
+
const reconnectStartTime = Date.now();
|
|
10873
10797
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
10874
10798
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
10875
10799
|
await this.join(this.joinCallData);
|
|
10876
10800
|
await this.restorePublishedTracks();
|
|
10877
10801
|
this.restoreSubscribedTracks();
|
|
10878
|
-
this.sfuStatsReporter?.
|
|
10879
|
-
data: {
|
|
10880
|
-
oneofKind: 'reconnection',
|
|
10881
|
-
reconnection: {
|
|
10882
|
-
timeSeconds: (Date.now() - reconnectStartTime) / 1000,
|
|
10883
|
-
strategy: WebsocketReconnectStrategy.REJOIN,
|
|
10884
|
-
},
|
|
10885
|
-
},
|
|
10886
|
-
});
|
|
10802
|
+
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
|
|
10887
10803
|
};
|
|
10888
10804
|
/**
|
|
10889
10805
|
* Initiates the reconnection flow with the "migrate" strategy.
|
|
10890
10806
|
* @internal
|
|
10891
10807
|
*/
|
|
10892
10808
|
this.reconnectMigrate = async () => {
|
|
10893
|
-
|
|
10809
|
+
const reconnectStartTime = Date.now();
|
|
10894
10810
|
const currentSfuClient = this.sfuClient;
|
|
10895
10811
|
if (!currentSfuClient) {
|
|
10896
10812
|
throw new Error('Cannot migrate without an active SFU client');
|
|
@@ -10924,20 +10840,12 @@ class Call {
|
|
|
10924
10840
|
this.state.setCallingState(CallingState.JOINED);
|
|
10925
10841
|
}
|
|
10926
10842
|
finally {
|
|
10927
|
-
currentSubscriber?.
|
|
10928
|
-
currentPublisher?.
|
|
10843
|
+
currentSubscriber?.dispose();
|
|
10844
|
+
currentPublisher?.dispose();
|
|
10929
10845
|
// and close the previous SFU client, without specifying close code
|
|
10930
10846
|
currentSfuClient.close();
|
|
10931
10847
|
}
|
|
10932
|
-
this.sfuStatsReporter?.
|
|
10933
|
-
data: {
|
|
10934
|
-
oneofKind: 'reconnection',
|
|
10935
|
-
reconnection: {
|
|
10936
|
-
timeSeconds: (Date.now() - reconnectStartTime) / 1000,
|
|
10937
|
-
strategy: WebsocketReconnectStrategy.MIGRATE,
|
|
10938
|
-
},
|
|
10939
|
-
},
|
|
10940
|
-
});
|
|
10848
|
+
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.MIGRATE, (Date.now() - reconnectStartTime) / 1000);
|
|
10941
10849
|
};
|
|
10942
10850
|
/**
|
|
10943
10851
|
* Registers the various event handlers for reconnection.
|
|
@@ -11014,23 +10922,16 @@ class Call {
|
|
|
11014
10922
|
// the tracks need to be restored in their original order of publishing
|
|
11015
10923
|
// otherwise, we might get `m-lines order mismatch` errors
|
|
11016
10924
|
for (const trackType of this.trackPublishOrder) {
|
|
10925
|
+
let mediaStream;
|
|
11017
10926
|
switch (trackType) {
|
|
11018
10927
|
case TrackType.AUDIO:
|
|
11019
|
-
|
|
11020
|
-
if (audioStream) {
|
|
11021
|
-
await this.publishAudioStream(audioStream);
|
|
11022
|
-
}
|
|
10928
|
+
mediaStream = this.microphone.state.mediaStream;
|
|
11023
10929
|
break;
|
|
11024
10930
|
case TrackType.VIDEO:
|
|
11025
|
-
|
|
11026
|
-
if (videoStream)
|
|
11027
|
-
await this.publishVideoStream(videoStream);
|
|
10931
|
+
mediaStream = this.camera.state.mediaStream;
|
|
11028
10932
|
break;
|
|
11029
10933
|
case TrackType.SCREEN_SHARE:
|
|
11030
|
-
|
|
11031
|
-
if (screenShareStream) {
|
|
11032
|
-
await this.publishScreenShareStream(screenShareStream);
|
|
11033
|
-
}
|
|
10934
|
+
mediaStream = this.screenShare.state.mediaStream;
|
|
11034
10935
|
break;
|
|
11035
10936
|
// screen share audio can't exist without a screen share, so we handle it there
|
|
11036
10937
|
case TrackType.SCREEN_SHARE_AUDIO:
|
|
@@ -11040,6 +10941,8 @@ class Call {
|
|
|
11040
10941
|
ensureExhausted(trackType, 'Unknown track type');
|
|
11041
10942
|
break;
|
|
11042
10943
|
}
|
|
10944
|
+
if (mediaStream)
|
|
10945
|
+
await this.publish(mediaStream, trackType);
|
|
11043
10946
|
}
|
|
11044
10947
|
};
|
|
11045
10948
|
/**
|
|
@@ -11054,105 +10957,111 @@ class Call {
|
|
|
11054
10957
|
};
|
|
11055
10958
|
/**
|
|
11056
10959
|
* Starts publishing the given video stream to the call.
|
|
11057
|
-
*
|
|
11058
|
-
*
|
|
11059
|
-
* Consecutive calls to this method will replace the previously published stream.
|
|
11060
|
-
* The previous video stream will be stopped.
|
|
11061
|
-
*
|
|
11062
|
-
* @param videoStream the video stream to publish.
|
|
10960
|
+
* @deprecated use `call.publish()`.
|
|
11063
10961
|
*/
|
|
11064
10962
|
this.publishVideoStream = async (videoStream) => {
|
|
11065
|
-
|
|
11066
|
-
throw new Error(`Call not joined yet.`);
|
|
11067
|
-
// joining is in progress, and we should wait until the client is ready
|
|
11068
|
-
await this.sfuClient.joinTask;
|
|
11069
|
-
if (!this.permissionsContext.hasPermission(OwnCapability.SEND_VIDEO)) {
|
|
11070
|
-
throw new Error('No permission to publish video');
|
|
11071
|
-
}
|
|
11072
|
-
if (!this.publisher)
|
|
11073
|
-
throw new Error('Publisher is not initialized');
|
|
11074
|
-
const [videoTrack] = videoStream.getVideoTracks();
|
|
11075
|
-
if (!videoTrack)
|
|
11076
|
-
throw new Error('There is no video track in the stream');
|
|
11077
|
-
if (!this.trackPublishOrder.includes(TrackType.VIDEO)) {
|
|
11078
|
-
this.trackPublishOrder.push(TrackType.VIDEO);
|
|
11079
|
-
}
|
|
11080
|
-
await this.publisher.publishStream(videoStream, videoTrack, TrackType.VIDEO, this.publishOptions);
|
|
10963
|
+
await this.publish(videoStream, TrackType.VIDEO);
|
|
11081
10964
|
};
|
|
11082
10965
|
/**
|
|
11083
10966
|
* Starts publishing the given audio stream to the call.
|
|
11084
|
-
*
|
|
11085
|
-
*
|
|
11086
|
-
* Consecutive calls to this method will replace the audio stream that is currently being published.
|
|
11087
|
-
* The previous audio stream will be stopped.
|
|
11088
|
-
*
|
|
11089
|
-
* @param audioStream the audio stream to publish.
|
|
10967
|
+
* @deprecated use `call.publish()`
|
|
11090
10968
|
*/
|
|
11091
10969
|
this.publishAudioStream = async (audioStream) => {
|
|
11092
|
-
|
|
11093
|
-
throw new Error(`Call not joined yet.`);
|
|
11094
|
-
// joining is in progress, and we should wait until the client is ready
|
|
11095
|
-
await this.sfuClient.joinTask;
|
|
11096
|
-
if (!this.permissionsContext.hasPermission(OwnCapability.SEND_AUDIO)) {
|
|
11097
|
-
throw new Error('No permission to publish audio');
|
|
11098
|
-
}
|
|
11099
|
-
if (!this.publisher)
|
|
11100
|
-
throw new Error('Publisher is not initialized');
|
|
11101
|
-
const [audioTrack] = audioStream.getAudioTracks();
|
|
11102
|
-
if (!audioTrack)
|
|
11103
|
-
throw new Error('There is no audio track in the stream');
|
|
11104
|
-
if (!this.trackPublishOrder.includes(TrackType.AUDIO)) {
|
|
11105
|
-
this.trackPublishOrder.push(TrackType.AUDIO);
|
|
11106
|
-
}
|
|
11107
|
-
await this.publisher.publishStream(audioStream, audioTrack, TrackType.AUDIO);
|
|
10970
|
+
await this.publish(audioStream, TrackType.AUDIO);
|
|
11108
10971
|
};
|
|
11109
10972
|
/**
|
|
11110
10973
|
* Starts publishing the given screen-share stream to the call.
|
|
11111
|
-
*
|
|
11112
|
-
* Consecutive calls to this method will replace the previous screen-share stream.
|
|
11113
|
-
* The previous screen-share stream will be stopped.
|
|
11114
|
-
*
|
|
11115
|
-
* @param screenShareStream the screen-share stream to publish.
|
|
10974
|
+
* @deprecated use `call.publish()`
|
|
11116
10975
|
*/
|
|
11117
10976
|
this.publishScreenShareStream = async (screenShareStream) => {
|
|
10977
|
+
await this.publish(screenShareStream, TrackType.SCREEN_SHARE);
|
|
10978
|
+
};
|
|
10979
|
+
/**
|
|
10980
|
+
* Publishes the given media stream.
|
|
10981
|
+
*
|
|
10982
|
+
* @param mediaStream the media stream to publish.
|
|
10983
|
+
* @param trackType the type of the track to announce.
|
|
10984
|
+
*/
|
|
10985
|
+
this.publish = async (mediaStream, trackType) => {
|
|
11118
10986
|
if (!this.sfuClient)
|
|
11119
10987
|
throw new Error(`Call not joined yet.`);
|
|
11120
10988
|
// joining is in progress, and we should wait until the client is ready
|
|
11121
10989
|
await this.sfuClient.joinTask;
|
|
11122
|
-
if (!this.permissionsContext.
|
|
11123
|
-
throw new Error(
|
|
10990
|
+
if (!this.permissionsContext.canPublish(trackType)) {
|
|
10991
|
+
throw new Error(`No permission to publish ${TrackType[trackType]}`);
|
|
11124
10992
|
}
|
|
11125
10993
|
if (!this.publisher)
|
|
11126
10994
|
throw new Error('Publisher is not initialized');
|
|
11127
|
-
const [
|
|
11128
|
-
|
|
11129
|
-
|
|
10995
|
+
const [track] = isAudioTrackType(trackType)
|
|
10996
|
+
? mediaStream.getAudioTracks()
|
|
10997
|
+
: mediaStream.getVideoTracks();
|
|
10998
|
+
if (!track) {
|
|
10999
|
+
throw new Error(`There is no ${TrackType[trackType]} track in the stream`);
|
|
11130
11000
|
}
|
|
11131
|
-
if (
|
|
11132
|
-
|
|
11133
|
-
}
|
|
11134
|
-
|
|
11135
|
-
|
|
11136
|
-
|
|
11137
|
-
|
|
11138
|
-
|
|
11139
|
-
|
|
11140
|
-
|
|
11141
|
-
this.
|
|
11001
|
+
if (track.readyState === 'ended') {
|
|
11002
|
+
throw new Error(`Can't publish ended tracks.`);
|
|
11003
|
+
}
|
|
11004
|
+
pushToIfMissing(this.trackPublishOrder, trackType);
|
|
11005
|
+
await this.publisher.publish(track, trackType);
|
|
11006
|
+
const trackTypes = [trackType];
|
|
11007
|
+
if (trackType === TrackType.SCREEN_SHARE) {
|
|
11008
|
+
const [audioTrack] = mediaStream.getAudioTracks();
|
|
11009
|
+
if (audioTrack) {
|
|
11010
|
+
pushToIfMissing(this.trackPublishOrder, TrackType.SCREEN_SHARE_AUDIO);
|
|
11011
|
+
await this.publisher.publish(audioTrack, TrackType.SCREEN_SHARE_AUDIO);
|
|
11012
|
+
trackTypes.push(TrackType.SCREEN_SHARE_AUDIO);
|
|
11142
11013
|
}
|
|
11143
|
-
await this.publisher.publishStream(screenShareStream, screenShareAudioTrack, TrackType.SCREEN_SHARE_AUDIO, opts);
|
|
11144
11014
|
}
|
|
11015
|
+
await this.updateLocalStreamState(mediaStream, ...trackTypes);
|
|
11145
11016
|
};
|
|
11146
11017
|
/**
|
|
11147
11018
|
* Stops publishing the given track type to the call, if it is currently being published.
|
|
11148
|
-
* Underlying track will be stopped and removed from the publisher.
|
|
11149
11019
|
*
|
|
11150
|
-
* @param
|
|
11151
|
-
|
|
11020
|
+
* @param trackTypes the track types to stop publishing.
|
|
11021
|
+
*/
|
|
11022
|
+
this.stopPublish = async (...trackTypes) => {
|
|
11023
|
+
if (!this.sfuClient || !this.publisher)
|
|
11024
|
+
return;
|
|
11025
|
+
this.publisher.stopTracks(...trackTypes);
|
|
11026
|
+
await this.updateLocalStreamState(undefined, ...trackTypes);
|
|
11027
|
+
};
|
|
11028
|
+
/**
|
|
11029
|
+
* Updates the call state with the new stream.
|
|
11030
|
+
*
|
|
11031
|
+
* @param mediaStream the new stream to update the call state with.
|
|
11032
|
+
* If undefined, the stream will be removed from the call state.
|
|
11033
|
+
* @param trackTypes the track types to update the call state with.
|
|
11034
|
+
*/
|
|
11035
|
+
this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
|
|
11036
|
+
if (!this.sfuClient || !this.sfuClient.sessionId)
|
|
11037
|
+
return;
|
|
11038
|
+
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
11039
|
+
const { sessionId } = this.sfuClient;
|
|
11040
|
+
for (const trackType of trackTypes) {
|
|
11041
|
+
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
11042
|
+
if (!streamStateProp)
|
|
11043
|
+
continue;
|
|
11044
|
+
this.state.updateParticipant(sessionId, (p) => ({
|
|
11045
|
+
publishedTracks: mediaStream
|
|
11046
|
+
? pushToIfMissing([...p.publishedTracks], trackType)
|
|
11047
|
+
: p.publishedTracks.filter((t) => t !== trackType),
|
|
11048
|
+
[streamStateProp]: mediaStream,
|
|
11049
|
+
}));
|
|
11050
|
+
}
|
|
11051
|
+
};
|
|
11052
|
+
/**
|
|
11053
|
+
* Updates the preferred publishing options
|
|
11054
|
+
*
|
|
11055
|
+
* @internal
|
|
11056
|
+
* @param options the options to use.
|
|
11152
11057
|
*/
|
|
11153
|
-
this.
|
|
11154
|
-
this.logger('
|
|
11155
|
-
|
|
11058
|
+
this.updatePublishOptions = (options) => {
|
|
11059
|
+
this.logger('warn', '[call.updatePublishOptions]: You are manually overriding the publish options for this call. ' +
|
|
11060
|
+
'This is not recommended, and it can cause call stability/compatibility issues. Use with caution.');
|
|
11061
|
+
if (this.state.callingState === CallingState.JOINED) {
|
|
11062
|
+
this.logger('warn', 'Updating publish options after joining the call does not have an effect');
|
|
11063
|
+
}
|
|
11064
|
+
this.clientPublishOptions = { ...this.clientPublishOptions, ...options };
|
|
11156
11065
|
};
|
|
11157
11066
|
/**
|
|
11158
11067
|
* Notifies the SFU that a noise cancellation process has started.
|
|
@@ -11174,6 +11083,15 @@ class Call {
|
|
|
11174
11083
|
this.logger('warn', 'Failed to notify stop of noise cancellation', err);
|
|
11175
11084
|
});
|
|
11176
11085
|
};
|
|
11086
|
+
/**
|
|
11087
|
+
* Notifies the SFU about the mute state of the given track types.
|
|
11088
|
+
* @internal
|
|
11089
|
+
*/
|
|
11090
|
+
this.notifyTrackMuteState = async (muted, ...trackTypes) => {
|
|
11091
|
+
if (!this.sfuClient)
|
|
11092
|
+
return;
|
|
11093
|
+
await this.sfuClient.updateMuteStates(trackTypes.map((trackType) => ({ trackType, muted })));
|
|
11094
|
+
};
|
|
11177
11095
|
/**
|
|
11178
11096
|
* Will enhance the reported stats with additional participant-specific information (`callStatsReport$` state [store variable](./StreamVideoClient.md/#readonlystatestore)).
|
|
11179
11097
|
* This is usually helpful when detailed stats for a specific participant are needed.
|
|
@@ -11637,70 +11555,14 @@ class Call {
|
|
|
11637
11555
|
*
|
|
11638
11556
|
* @internal
|
|
11639
11557
|
*/
|
|
11640
|
-
this.applyDeviceConfig = async (
|
|
11641
|
-
await this.
|
|
11558
|
+
this.applyDeviceConfig = async (settings, publish) => {
|
|
11559
|
+
await this.camera.apply(settings.video, publish).catch((err) => {
|
|
11642
11560
|
this.logger('warn', 'Camera init failed', err);
|
|
11643
11561
|
});
|
|
11644
|
-
await this.
|
|
11562
|
+
await this.microphone.apply(settings.audio, publish).catch((err) => {
|
|
11645
11563
|
this.logger('warn', 'Mic init failed', err);
|
|
11646
11564
|
});
|
|
11647
11565
|
};
|
|
11648
|
-
this.initCamera = async (options) => {
|
|
11649
|
-
// Wait for any in progress camera operation
|
|
11650
|
-
await this.camera.statusChangeSettled();
|
|
11651
|
-
if (this.state.localParticipant?.videoStream ||
|
|
11652
|
-
!this.permissionsContext.hasPermission('send-video')) {
|
|
11653
|
-
return;
|
|
11654
|
-
}
|
|
11655
|
-
// Set camera direction if it's not yet set
|
|
11656
|
-
if (!this.camera.state.direction && !this.camera.state.selectedDevice) {
|
|
11657
|
-
let defaultDirection = 'front';
|
|
11658
|
-
const backendSetting = this.state.settings?.video.camera_facing;
|
|
11659
|
-
if (backendSetting) {
|
|
11660
|
-
defaultDirection = backendSetting === 'front' ? 'front' : 'back';
|
|
11661
|
-
}
|
|
11662
|
-
this.camera.state.setDirection(defaultDirection);
|
|
11663
|
-
}
|
|
11664
|
-
// Set target resolution
|
|
11665
|
-
const targetResolution = this.state.settings?.video.target_resolution;
|
|
11666
|
-
if (targetResolution) {
|
|
11667
|
-
await this.camera.selectTargetResolution(targetResolution);
|
|
11668
|
-
}
|
|
11669
|
-
if (options.setStatus) {
|
|
11670
|
-
// Publish already that was set before we joined
|
|
11671
|
-
if (this.camera.enabled &&
|
|
11672
|
-
this.camera.state.mediaStream &&
|
|
11673
|
-
!this.publisher?.isPublishing(TrackType.VIDEO)) {
|
|
11674
|
-
await this.publishVideoStream(this.camera.state.mediaStream);
|
|
11675
|
-
}
|
|
11676
|
-
// Start camera if backend config specifies, and there is no local setting
|
|
11677
|
-
if (this.camera.state.status === undefined &&
|
|
11678
|
-
this.state.settings?.video.camera_default_on) {
|
|
11679
|
-
await this.camera.enable();
|
|
11680
|
-
}
|
|
11681
|
-
}
|
|
11682
|
-
};
|
|
11683
|
-
this.initMic = async (options) => {
|
|
11684
|
-
// Wait for any in progress mic operation
|
|
11685
|
-
await this.microphone.statusChangeSettled();
|
|
11686
|
-
if (this.state.localParticipant?.audioStream ||
|
|
11687
|
-
!this.permissionsContext.hasPermission('send-audio')) {
|
|
11688
|
-
return;
|
|
11689
|
-
}
|
|
11690
|
-
if (options.setStatus) {
|
|
11691
|
-
// Publish media stream that was set before we joined
|
|
11692
|
-
if (this.microphone.enabled &&
|
|
11693
|
-
this.microphone.state.mediaStream &&
|
|
11694
|
-
!this.publisher?.isPublishing(TrackType.AUDIO)) {
|
|
11695
|
-
await this.publishAudioStream(this.microphone.state.mediaStream);
|
|
11696
|
-
}
|
|
11697
|
-
// Start mic if backend config specifies, and there is no local setting
|
|
11698
|
-
if (this.microphone.state.status === undefined &&
|
|
11699
|
-
this.state.settings?.audio.mic_default_on) {
|
|
11700
|
-
await this.microphone.enable();
|
|
11701
|
-
}
|
|
11702
|
-
}
|
|
11703
|
-
};
|
|
11704
11566
|
/**
|
|
11705
11567
|
* Will begin tracking the given element for visibility changes within the
|
|
11706
11568
|
* configured viewport element (`call.setViewport`).
|
|
@@ -11849,109 +11711,6 @@ class Call {
|
|
|
11849
11711
|
this.screenShare = new ScreenShareManager(this);
|
|
11850
11712
|
this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
|
|
11851
11713
|
}
|
|
11852
|
-
async setup() {
|
|
11853
|
-
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
11854
|
-
if (this.initialized)
|
|
11855
|
-
return;
|
|
11856
|
-
this.leaveCallHooks.add(this.on('all', (event) => {
|
|
11857
|
-
// update state with the latest event data
|
|
11858
|
-
this.state.updateFromEvent(event);
|
|
11859
|
-
}));
|
|
11860
|
-
this.leaveCallHooks.add(registerEventHandlers(this, this.dispatcher));
|
|
11861
|
-
this.registerEffects();
|
|
11862
|
-
this.registerReconnectHandlers();
|
|
11863
|
-
if (this.state.callingState === CallingState.LEFT) {
|
|
11864
|
-
this.state.setCallingState(CallingState.IDLE);
|
|
11865
|
-
}
|
|
11866
|
-
this.initialized = true;
|
|
11867
|
-
});
|
|
11868
|
-
}
|
|
11869
|
-
registerEffects() {
|
|
11870
|
-
this.leaveCallHooks.add(
|
|
11871
|
-
// handles updating the permissions context when the settings change.
|
|
11872
|
-
createSubscription(this.state.settings$, (settings) => {
|
|
11873
|
-
if (!settings)
|
|
11874
|
-
return;
|
|
11875
|
-
this.permissionsContext.setCallSettings(settings);
|
|
11876
|
-
}));
|
|
11877
|
-
this.leaveCallHooks.add(
|
|
11878
|
-
// handle the case when the user permissions are modified.
|
|
11879
|
-
createSafeAsyncSubscription(this.state.ownCapabilities$, this.handleOwnCapabilitiesUpdated));
|
|
11880
|
-
this.leaveCallHooks.add(
|
|
11881
|
-
// handles the case when the user is blocked by the call owner.
|
|
11882
|
-
createSubscription(this.state.blockedUserIds$, async (blockedUserIds) => {
|
|
11883
|
-
if (!blockedUserIds || blockedUserIds.length === 0)
|
|
11884
|
-
return;
|
|
11885
|
-
const currentUserId = this.currentUserId;
|
|
11886
|
-
if (currentUserId && blockedUserIds.includes(currentUserId)) {
|
|
11887
|
-
this.logger('info', 'Leaving call because of being blocked');
|
|
11888
|
-
await this.leave({ reason: 'user blocked' }).catch((err) => {
|
|
11889
|
-
this.logger('error', 'Error leaving call after being blocked', err);
|
|
11890
|
-
});
|
|
11891
|
-
}
|
|
11892
|
-
}));
|
|
11893
|
-
this.leaveCallHooks.add(
|
|
11894
|
-
// cancel auto-drop when call is
|
|
11895
|
-
createSubscription(this.state.session$, (session) => {
|
|
11896
|
-
if (!this.ringing)
|
|
11897
|
-
return;
|
|
11898
|
-
const receiverId = this.clientStore.connectedUser?.id;
|
|
11899
|
-
if (!receiverId)
|
|
11900
|
-
return;
|
|
11901
|
-
const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
|
|
11902
|
-
const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
|
|
11903
|
-
if (isAcceptedByMe || isRejectedByMe) {
|
|
11904
|
-
this.cancelAutoDrop();
|
|
11905
|
-
}
|
|
11906
|
-
}));
|
|
11907
|
-
this.leaveCallHooks.add(
|
|
11908
|
-
// "ringing" mode effects and event handlers
|
|
11909
|
-
createSubscription(this.ringingSubject, (isRinging) => {
|
|
11910
|
-
if (!isRinging)
|
|
11911
|
-
return;
|
|
11912
|
-
const callSession = this.state.session;
|
|
11913
|
-
const receiver_id = this.clientStore.connectedUser?.id;
|
|
11914
|
-
const ended_at = callSession?.ended_at;
|
|
11915
|
-
const created_by_id = this.state.createdBy?.id;
|
|
11916
|
-
const rejected_by = callSession?.rejected_by;
|
|
11917
|
-
const accepted_by = callSession?.accepted_by;
|
|
11918
|
-
let leaveCallIdle = false;
|
|
11919
|
-
if (ended_at) {
|
|
11920
|
-
// call was ended before it was accepted or rejected so we should leave it to idle
|
|
11921
|
-
leaveCallIdle = true;
|
|
11922
|
-
}
|
|
11923
|
-
else if (created_by_id && rejected_by) {
|
|
11924
|
-
if (rejected_by[created_by_id]) {
|
|
11925
|
-
// call was cancelled by the caller
|
|
11926
|
-
leaveCallIdle = true;
|
|
11927
|
-
}
|
|
11928
|
-
}
|
|
11929
|
-
else if (receiver_id && rejected_by) {
|
|
11930
|
-
if (rejected_by[receiver_id]) {
|
|
11931
|
-
// call was rejected by the receiver in some other device
|
|
11932
|
-
leaveCallIdle = true;
|
|
11933
|
-
}
|
|
11934
|
-
}
|
|
11935
|
-
else if (receiver_id && accepted_by) {
|
|
11936
|
-
if (accepted_by[receiver_id]) {
|
|
11937
|
-
// call was accepted by the receiver in some other device
|
|
11938
|
-
leaveCallIdle = true;
|
|
11939
|
-
}
|
|
11940
|
-
}
|
|
11941
|
-
if (leaveCallIdle) {
|
|
11942
|
-
if (this.state.callingState !== CallingState.IDLE) {
|
|
11943
|
-
this.state.setCallingState(CallingState.IDLE);
|
|
11944
|
-
}
|
|
11945
|
-
}
|
|
11946
|
-
else {
|
|
11947
|
-
if (this.state.callingState === CallingState.IDLE) {
|
|
11948
|
-
this.state.setCallingState(CallingState.RINGING);
|
|
11949
|
-
}
|
|
11950
|
-
this.scheduleAutoDrop();
|
|
11951
|
-
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
11952
|
-
}
|
|
11953
|
-
}));
|
|
11954
|
-
}
|
|
11955
11714
|
/**
|
|
11956
11715
|
* A flag indicating whether the call is "ringing" type of call.
|
|
11957
11716
|
*/
|
|
@@ -11970,15 +11729,6 @@ class Call {
|
|
|
11970
11729
|
get isCreatedByMe() {
|
|
11971
11730
|
return this.state.createdBy?.id === this.currentUserId;
|
|
11972
11731
|
}
|
|
11973
|
-
/**
|
|
11974
|
-
* Updates the preferred publishing options
|
|
11975
|
-
*
|
|
11976
|
-
* @internal
|
|
11977
|
-
* @param options the options to use.
|
|
11978
|
-
*/
|
|
11979
|
-
updatePublishOptions(options) {
|
|
11980
|
-
this.publishOptions = { ...this.publishOptions, ...options };
|
|
11981
|
-
}
|
|
11982
11732
|
}
|
|
11983
11733
|
|
|
11984
11734
|
var https = null;
|
|
@@ -13088,7 +12838,7 @@ class StreamClient {
|
|
|
13088
12838
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
13089
12839
|
};
|
|
13090
12840
|
this.getUserAgent = () => {
|
|
13091
|
-
const version = "1.
|
|
12841
|
+
const version = "1.15.1";
|
|
13092
12842
|
return (this.userAgent ||
|
|
13093
12843
|
`stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
|
|
13094
12844
|
};
|
|
@@ -13388,7 +13138,7 @@ class StreamVideoClient {
|
|
|
13388
13138
|
clientStore: this.writeableStateStore,
|
|
13389
13139
|
});
|
|
13390
13140
|
call.state.updateFromCallResponse(c.call);
|
|
13391
|
-
await call.applyDeviceConfig(false);
|
|
13141
|
+
await call.applyDeviceConfig(c.call.settings, false);
|
|
13392
13142
|
if (data.watch) {
|
|
13393
13143
|
this.writeableStateStore.registerCall(call);
|
|
13394
13144
|
}
|