@stream-io/video-client 1.49.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +89 -22
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
package/dist/index.cjs.js
CHANGED
|
@@ -1418,6 +1418,35 @@ var ClientCapability;
|
|
|
1418
1418
|
*/
|
|
1419
1419
|
ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
|
|
1420
1420
|
})(ClientCapability || (ClientCapability = {}));
|
|
1421
|
+
/**
|
|
1422
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1423
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1424
|
+
*
|
|
1425
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1426
|
+
*/
|
|
1427
|
+
var DegradationPreference;
|
|
1428
|
+
(function (DegradationPreference) {
|
|
1429
|
+
/**
|
|
1430
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1431
|
+
*/
|
|
1432
|
+
DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
1433
|
+
/**
|
|
1434
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1435
|
+
*/
|
|
1436
|
+
DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
|
|
1437
|
+
/**
|
|
1438
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1439
|
+
*/
|
|
1440
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
|
|
1441
|
+
/**
|
|
1442
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1443
|
+
*/
|
|
1444
|
+
DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
|
|
1445
|
+
/**
|
|
1446
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1447
|
+
*/
|
|
1448
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
|
|
1449
|
+
})(DegradationPreference || (DegradationPreference = {}));
|
|
1421
1450
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1422
1451
|
class CallState$Type extends runtime.MessageType {
|
|
1423
1452
|
constructor() {
|
|
@@ -1687,6 +1716,16 @@ class PublishOption$Type extends runtime.MessageType {
|
|
|
1687
1716
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1688
1717
|
T: () => AudioBitrate,
|
|
1689
1718
|
},
|
|
1719
|
+
{
|
|
1720
|
+
no: 11,
|
|
1721
|
+
name: 'degradation_preference',
|
|
1722
|
+
kind: 'enum',
|
|
1723
|
+
T: () => [
|
|
1724
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1725
|
+
DegradationPreference,
|
|
1726
|
+
'DEGRADATION_PREFERENCE_',
|
|
1727
|
+
],
|
|
1728
|
+
},
|
|
1690
1729
|
]);
|
|
1691
1730
|
}
|
|
1692
1731
|
}
|
|
@@ -2133,6 +2172,7 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
2133
2172
|
ClientDetails: ClientDetails,
|
|
2134
2173
|
Codec: Codec,
|
|
2135
2174
|
get ConnectionQuality () { return ConnectionQuality; },
|
|
2175
|
+
get DegradationPreference () { return DegradationPreference; },
|
|
2136
2176
|
Device: Device,
|
|
2137
2177
|
Error: Error$2,
|
|
2138
2178
|
get ErrorCode () { return ErrorCode; },
|
|
@@ -3520,6 +3560,16 @@ class VideoSender$Type extends runtime.MessageType {
|
|
|
3520
3560
|
kind: 'scalar',
|
|
3521
3561
|
T: 5 /*ScalarType.INT32*/,
|
|
3522
3562
|
},
|
|
3563
|
+
{
|
|
3564
|
+
no: 6,
|
|
3565
|
+
name: 'degradation_preference',
|
|
3566
|
+
kind: 'enum',
|
|
3567
|
+
T: () => [
|
|
3568
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
3569
|
+
DegradationPreference,
|
|
3570
|
+
'DEGRADATION_PREFERENCE_',
|
|
3571
|
+
],
|
|
3572
|
+
},
|
|
3523
3573
|
]);
|
|
3524
3574
|
}
|
|
3525
3575
|
}
|
|
@@ -3885,6 +3935,18 @@ const createSignalClient = (options) => {
|
|
|
3885
3935
|
};
|
|
3886
3936
|
|
|
3887
3937
|
const sleep = (m) => new Promise((r) => setTimeout(r, m));
|
|
3938
|
+
const timeboxed = async (promises, ms) => {
|
|
3939
|
+
let timerId;
|
|
3940
|
+
const timeout = new Promise((_, reject) => {
|
|
3941
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
3942
|
+
});
|
|
3943
|
+
try {
|
|
3944
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
3945
|
+
}
|
|
3946
|
+
finally {
|
|
3947
|
+
clearTimeout(timerId);
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3888
3950
|
function isFunction(value) {
|
|
3889
3951
|
return (value &&
|
|
3890
3952
|
(Object.prototype.toString.call(value) === '[object Function]' ||
|
|
@@ -4624,6 +4686,20 @@ const setCurrentValue = (subject, update) => {
|
|
|
4624
4686
|
subject.next(next);
|
|
4625
4687
|
return next;
|
|
4626
4688
|
};
|
|
4689
|
+
/**
|
|
4690
|
+
* Updates the value of the provided Subject asynchronously.
|
|
4691
|
+
* Locks the subject to prevent concurrent updates.
|
|
4692
|
+
*
|
|
4693
|
+
* @param subject the subject to update.
|
|
4694
|
+
* @param update the update to apply to the subject.
|
|
4695
|
+
*/
|
|
4696
|
+
const setCurrentValueAsync = async (subject, update) => {
|
|
4697
|
+
return withoutConcurrency(subject, async () => {
|
|
4698
|
+
const next = await update(getCurrentValue(subject));
|
|
4699
|
+
subject.next(next);
|
|
4700
|
+
return next;
|
|
4701
|
+
});
|
|
4702
|
+
};
|
|
4627
4703
|
/**
|
|
4628
4704
|
* Updates the value of the provided Subject and returns the previous value
|
|
4629
4705
|
* and a function to roll back the update.
|
|
@@ -4678,6 +4754,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
|
|
|
4678
4754
|
createSubscription: createSubscription,
|
|
4679
4755
|
getCurrentValue: getCurrentValue,
|
|
4680
4756
|
setCurrentValue: setCurrentValue,
|
|
4757
|
+
setCurrentValueAsync: setCurrentValueAsync,
|
|
4681
4758
|
updateValue: updateValue
|
|
4682
4759
|
});
|
|
4683
4760
|
|
|
@@ -6302,7 +6379,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6302
6379
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6303
6380
|
};
|
|
6304
6381
|
|
|
6305
|
-
const version = "1.
|
|
6382
|
+
const version = "1.50.0";
|
|
6306
6383
|
const [major, minor, patch] = version.split('.');
|
|
6307
6384
|
let sdkInfo = {
|
|
6308
6385
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -6455,6 +6532,31 @@ const isSafari = () => {
|
|
|
6455
6532
|
return false;
|
|
6456
6533
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
6457
6534
|
};
|
|
6535
|
+
/**
|
|
6536
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
6537
|
+
*
|
|
6538
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
6539
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
6540
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
6541
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
6542
|
+
* share the underlying WebKit quirks.
|
|
6543
|
+
*
|
|
6544
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
6545
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
6546
|
+
*/
|
|
6547
|
+
const isWebKit = () => {
|
|
6548
|
+
if (typeof navigator === 'undefined')
|
|
6549
|
+
return false;
|
|
6550
|
+
const ua = navigator.userAgent || '';
|
|
6551
|
+
if (!/AppleWebKit\//.test(ua))
|
|
6552
|
+
return false;
|
|
6553
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
6554
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
6555
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
6556
|
+
// the mobile Blink stack.
|
|
6557
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
6558
|
+
return !regExp.test(ua);
|
|
6559
|
+
};
|
|
6458
6560
|
/**
|
|
6459
6561
|
* Checks whether the current browser is Firefox.
|
|
6460
6562
|
*/
|
|
@@ -6498,7 +6600,8 @@ var browsers = /*#__PURE__*/Object.freeze({
|
|
|
6498
6600
|
isChrome: isChrome,
|
|
6499
6601
|
isFirefox: isFirefox,
|
|
6500
6602
|
isSafari: isSafari,
|
|
6501
|
-
isSupportedBrowser: isSupportedBrowser
|
|
6603
|
+
isSupportedBrowser: isSupportedBrowser,
|
|
6604
|
+
isWebKit: isWebKit
|
|
6502
6605
|
});
|
|
6503
6606
|
|
|
6504
6607
|
/**
|
|
@@ -7974,6 +8077,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
|
|
|
7974
8077
|
}));
|
|
7975
8078
|
};
|
|
7976
8079
|
|
|
8080
|
+
const toRTCDegradationPreference = (preference) => {
|
|
8081
|
+
switch (preference) {
|
|
8082
|
+
case DegradationPreference.BALANCED:
|
|
8083
|
+
return 'balanced';
|
|
8084
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
8085
|
+
return 'maintain-framerate';
|
|
8086
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
8087
|
+
return 'maintain-resolution';
|
|
8088
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
8089
|
+
// @ts-expect-error not in the typedefs yet
|
|
8090
|
+
return 'maintain-framerate-and-resolution';
|
|
8091
|
+
case DegradationPreference.UNSPECIFIED:
|
|
8092
|
+
return undefined;
|
|
8093
|
+
default:
|
|
8094
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8095
|
+
}
|
|
8096
|
+
};
|
|
8097
|
+
|
|
7977
8098
|
/**
|
|
7978
8099
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
7979
8100
|
*
|
|
@@ -8035,7 +8156,9 @@ class Publisher extends BasePeerConnection {
|
|
|
8035
8156
|
sendEncodings,
|
|
8036
8157
|
});
|
|
8037
8158
|
const params = transceiver.sender.getParameters();
|
|
8038
|
-
params.degradationPreference =
|
|
8159
|
+
params.degradationPreference =
|
|
8160
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
8161
|
+
'maintain-framerate';
|
|
8039
8162
|
await transceiver.sender.setParameters(params);
|
|
8040
8163
|
const trackType = publishOption.trackType;
|
|
8041
8164
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
@@ -8132,6 +8255,40 @@ class Publisher extends BasePeerConnection {
|
|
|
8132
8255
|
}
|
|
8133
8256
|
return false;
|
|
8134
8257
|
};
|
|
8258
|
+
/**
|
|
8259
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
8260
|
+
* reattaching the currently published track on each matching sender.
|
|
8261
|
+
*
|
|
8262
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
8263
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
8264
|
+
* can stop producing RTP packets even though the underlying
|
|
8265
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
8266
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
8267
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
8268
|
+
* flow with the same SSRC.
|
|
8269
|
+
*
|
|
8270
|
+
* No-op when nothing is published for the given track type.
|
|
8271
|
+
*
|
|
8272
|
+
* @param trackType the track type to refresh.
|
|
8273
|
+
*/
|
|
8274
|
+
this.refreshTrack = async (trackType) => {
|
|
8275
|
+
for (const item of this.transceiverCache.items()) {
|
|
8276
|
+
if (item.publishOption.trackType !== trackType)
|
|
8277
|
+
continue;
|
|
8278
|
+
const { sender } = item.transceiver;
|
|
8279
|
+
const track = sender.track;
|
|
8280
|
+
if (!track || track.readyState !== 'live')
|
|
8281
|
+
continue;
|
|
8282
|
+
try {
|
|
8283
|
+
await sender.replaceTrack(null);
|
|
8284
|
+
await sender.replaceTrack(track);
|
|
8285
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
8286
|
+
}
|
|
8287
|
+
catch (err) {
|
|
8288
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
8289
|
+
}
|
|
8290
|
+
}
|
|
8291
|
+
};
|
|
8135
8292
|
/**
|
|
8136
8293
|
* Stops the cloned track that is being published to the SFU.
|
|
8137
8294
|
*/
|
|
@@ -8209,6 +8366,12 @@ class Publisher extends BasePeerConnection {
|
|
|
8209
8366
|
changed = true;
|
|
8210
8367
|
}
|
|
8211
8368
|
}
|
|
8369
|
+
const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
|
|
8370
|
+
if (degradationPreference &&
|
|
8371
|
+
params.degradationPreference !== degradationPreference) {
|
|
8372
|
+
params.degradationPreference = degradationPreference;
|
|
8373
|
+
changed = true;
|
|
8374
|
+
}
|
|
8212
8375
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
8213
8376
|
if (!changed) {
|
|
8214
8377
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -8389,6 +8552,36 @@ class Publisher extends BasePeerConnection {
|
|
|
8389
8552
|
}
|
|
8390
8553
|
}
|
|
8391
8554
|
|
|
8555
|
+
/**
|
|
8556
|
+
* Adds unique values to an array.
|
|
8557
|
+
*
|
|
8558
|
+
* @param arr the array to add to.
|
|
8559
|
+
* @param values the values to add.
|
|
8560
|
+
*/
|
|
8561
|
+
const pushToIfMissing = (arr, ...values) => {
|
|
8562
|
+
for (const v of values) {
|
|
8563
|
+
if (!arr.includes(v)) {
|
|
8564
|
+
arr.push(v);
|
|
8565
|
+
}
|
|
8566
|
+
}
|
|
8567
|
+
return arr;
|
|
8568
|
+
};
|
|
8569
|
+
/**
|
|
8570
|
+
* Removes values from an array if they are present.
|
|
8571
|
+
*
|
|
8572
|
+
* @param arr the array to remove from.
|
|
8573
|
+
* @param values the values to remove.
|
|
8574
|
+
*/
|
|
8575
|
+
const removeFromIfPresent = (arr, ...values) => {
|
|
8576
|
+
for (const v of values) {
|
|
8577
|
+
const index = arr.indexOf(v);
|
|
8578
|
+
if (index !== -1) {
|
|
8579
|
+
arr.splice(index, 1);
|
|
8580
|
+
}
|
|
8581
|
+
}
|
|
8582
|
+
return arr;
|
|
8583
|
+
};
|
|
8584
|
+
|
|
8392
8585
|
/**
|
|
8393
8586
|
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
8394
8587
|
* media streams from the SFU.
|
|
@@ -8430,27 +8623,34 @@ class Subscriber extends BasePeerConnection {
|
|
|
8430
8623
|
}
|
|
8431
8624
|
};
|
|
8432
8625
|
this.handleOnTrack = (e) => {
|
|
8433
|
-
const
|
|
8626
|
+
const { streams, track } = e;
|
|
8627
|
+
const [primaryStream] = streams;
|
|
8434
8628
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
8435
8629
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
8436
8630
|
const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8437
|
-
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
8631
|
+
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
|
|
8632
|
+
const trackType = toTrackType(rawTrackType);
|
|
8633
|
+
if (!trackType) {
|
|
8634
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8635
|
+
}
|
|
8438
8636
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
8439
|
-
|
|
8637
|
+
track.addEventListener('mute', () => {
|
|
8440
8638
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
8639
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8441
8640
|
});
|
|
8442
|
-
|
|
8641
|
+
track.addEventListener('unmute', () => {
|
|
8443
8642
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
8643
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8444
8644
|
});
|
|
8445
|
-
|
|
8645
|
+
track.addEventListener('ended', () => {
|
|
8446
8646
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
8647
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8447
8648
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
8448
8649
|
});
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8650
|
+
if (track.muted) {
|
|
8651
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8452
8652
|
}
|
|
8453
|
-
this.trackIdToTrackType.set(
|
|
8653
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
8454
8654
|
if (!participantToUpdate) {
|
|
8455
8655
|
this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
|
|
8456
8656
|
this.state.registerOrphanedTrack({
|
|
@@ -8476,13 +8676,30 @@ class Subscriber extends BasePeerConnection {
|
|
|
8476
8676
|
});
|
|
8477
8677
|
// now, dispose the previous stream if it exists
|
|
8478
8678
|
if (previousStream) {
|
|
8479
|
-
this.logger.info(`[onTrack]: Cleaning up previous remote ${
|
|
8679
|
+
this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
8480
8680
|
previousStream.getTracks().forEach((t) => {
|
|
8481
8681
|
t.stop();
|
|
8482
8682
|
previousStream.removeTrack(t);
|
|
8483
8683
|
});
|
|
8484
8684
|
}
|
|
8485
8685
|
};
|
|
8686
|
+
this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
|
|
8687
|
+
if (trackType !== TrackType.AUDIO)
|
|
8688
|
+
return;
|
|
8689
|
+
const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8690
|
+
if (!target)
|
|
8691
|
+
return;
|
|
8692
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
8693
|
+
const current = p.interruptedTracks ?? [];
|
|
8694
|
+
const has = current.includes(trackType);
|
|
8695
|
+
if (interrupted === has)
|
|
8696
|
+
return {};
|
|
8697
|
+
const next = interrupted
|
|
8698
|
+
? pushToIfMissing([...current], trackType)
|
|
8699
|
+
: removeFromIfPresent([...current], trackType);
|
|
8700
|
+
return { interruptedTracks: next };
|
|
8701
|
+
});
|
|
8702
|
+
};
|
|
8486
8703
|
this.negotiate = async (subscriberOffer) => {
|
|
8487
8704
|
await this.pc.setRemoteDescription({
|
|
8488
8705
|
type: 'offer',
|
|
@@ -9205,36 +9422,6 @@ const watchCallGrantsUpdated = (state) => {
|
|
|
9205
9422
|
};
|
|
9206
9423
|
};
|
|
9207
9424
|
|
|
9208
|
-
/**
|
|
9209
|
-
* Adds unique values to an array.
|
|
9210
|
-
*
|
|
9211
|
-
* @param arr the array to add to.
|
|
9212
|
-
* @param values the values to add.
|
|
9213
|
-
*/
|
|
9214
|
-
const pushToIfMissing = (arr, ...values) => {
|
|
9215
|
-
for (const v of values) {
|
|
9216
|
-
if (!arr.includes(v)) {
|
|
9217
|
-
arr.push(v);
|
|
9218
|
-
}
|
|
9219
|
-
}
|
|
9220
|
-
return arr;
|
|
9221
|
-
};
|
|
9222
|
-
/**
|
|
9223
|
-
* Removes values from an array if they are present.
|
|
9224
|
-
*
|
|
9225
|
-
* @param arr the array to remove from.
|
|
9226
|
-
* @param values the values to remove.
|
|
9227
|
-
*/
|
|
9228
|
-
const removeFromIfPresent = (arr, ...values) => {
|
|
9229
|
-
for (const v of values) {
|
|
9230
|
-
const index = arr.indexOf(v);
|
|
9231
|
-
if (index !== -1) {
|
|
9232
|
-
arr.splice(index, 1);
|
|
9233
|
-
}
|
|
9234
|
-
}
|
|
9235
|
-
return arr;
|
|
9236
|
-
};
|
|
9237
|
-
|
|
9238
9425
|
const watchConnectionQualityChanged = (dispatcher, state) => {
|
|
9239
9426
|
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
9240
9427
|
const { connectionQualityUpdates } = e;
|
|
@@ -9567,91 +9754,6 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
9567
9754
|
};
|
|
9568
9755
|
};
|
|
9569
9756
|
|
|
9570
|
-
const DEFAULT_THRESHOLD = 0.35;
|
|
9571
|
-
class ViewportTracker {
|
|
9572
|
-
constructor() {
|
|
9573
|
-
/**
|
|
9574
|
-
* @private
|
|
9575
|
-
*/
|
|
9576
|
-
this.elementHandlerMap = new Map();
|
|
9577
|
-
/**
|
|
9578
|
-
* @private
|
|
9579
|
-
*/
|
|
9580
|
-
this.observer = null;
|
|
9581
|
-
// in React children render before viewport is set, add
|
|
9582
|
-
// them to the queue and observe them once the observer is ready
|
|
9583
|
-
/**
|
|
9584
|
-
* @private
|
|
9585
|
-
*/
|
|
9586
|
-
this.queueSet = new Set();
|
|
9587
|
-
/**
|
|
9588
|
-
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
9589
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9590
|
-
*
|
|
9591
|
-
* @param viewportElement
|
|
9592
|
-
* @param options
|
|
9593
|
-
* @returns Unobserve
|
|
9594
|
-
*/
|
|
9595
|
-
this.setViewport = (viewportElement, options) => {
|
|
9596
|
-
const cleanup = () => {
|
|
9597
|
-
this.observer?.disconnect();
|
|
9598
|
-
this.observer = null;
|
|
9599
|
-
this.elementHandlerMap.clear();
|
|
9600
|
-
};
|
|
9601
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
9602
|
-
entries.forEach((entry) => {
|
|
9603
|
-
const handler = this.elementHandlerMap.get(entry.target);
|
|
9604
|
-
handler?.(entry);
|
|
9605
|
-
});
|
|
9606
|
-
}, {
|
|
9607
|
-
root: viewportElement,
|
|
9608
|
-
...options,
|
|
9609
|
-
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
9610
|
-
});
|
|
9611
|
-
if (this.queueSet.size) {
|
|
9612
|
-
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
9613
|
-
// check if element which requested observation is
|
|
9614
|
-
// a child of a viewport element, skip if isn't
|
|
9615
|
-
if (!viewportElement.contains(queueElement))
|
|
9616
|
-
return;
|
|
9617
|
-
this.observer.observe(queueElement);
|
|
9618
|
-
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
9619
|
-
});
|
|
9620
|
-
this.queueSet.clear();
|
|
9621
|
-
}
|
|
9622
|
-
return cleanup;
|
|
9623
|
-
};
|
|
9624
|
-
/**
|
|
9625
|
-
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
9626
|
-
* detects a possible change in element's visibility within specified viewport, returns
|
|
9627
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9628
|
-
*
|
|
9629
|
-
* @param element
|
|
9630
|
-
* @param handler
|
|
9631
|
-
* @returns Unobserve
|
|
9632
|
-
*/
|
|
9633
|
-
this.observe = (element, handler) => {
|
|
9634
|
-
const queueItem = [element, handler];
|
|
9635
|
-
const cleanup = () => {
|
|
9636
|
-
this.elementHandlerMap.delete(element);
|
|
9637
|
-
this.observer?.unobserve(element);
|
|
9638
|
-
this.queueSet.delete(queueItem);
|
|
9639
|
-
};
|
|
9640
|
-
if (this.elementHandlerMap.has(element))
|
|
9641
|
-
return cleanup;
|
|
9642
|
-
if (!this.observer) {
|
|
9643
|
-
this.queueSet.add(queueItem);
|
|
9644
|
-
return cleanup;
|
|
9645
|
-
}
|
|
9646
|
-
if (this.observer.root.contains(element)) {
|
|
9647
|
-
this.elementHandlerMap.set(element, handler);
|
|
9648
|
-
this.observer.observe(element);
|
|
9649
|
-
}
|
|
9650
|
-
return cleanup;
|
|
9651
|
-
};
|
|
9652
|
-
}
|
|
9653
|
-
}
|
|
9654
|
-
|
|
9655
9757
|
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9656
9758
|
/**
|
|
9657
9759
|
* Tracks audio element bindings and periodically warns about
|
|
@@ -9659,8 +9761,6 @@ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${tr
|
|
|
9659
9761
|
*/
|
|
9660
9762
|
class AudioBindingsWatchdog {
|
|
9661
9763
|
constructor(state, tracer) {
|
|
9662
|
-
this.state = state;
|
|
9663
|
-
this.tracer = tracer;
|
|
9664
9764
|
this.bindings = new Map();
|
|
9665
9765
|
this.enabled = true;
|
|
9666
9766
|
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
@@ -9668,14 +9768,14 @@ class AudioBindingsWatchdog {
|
|
|
9668
9768
|
* Registers an audio element binding for the given session and track type.
|
|
9669
9769
|
* Warns if a different element is already bound to the same key.
|
|
9670
9770
|
*/
|
|
9671
|
-
this.register = (
|
|
9771
|
+
this.register = (element, sessionId, trackType) => {
|
|
9672
9772
|
const key = toBindingKey(sessionId, trackType);
|
|
9673
9773
|
const existing = this.bindings.get(key);
|
|
9674
|
-
if (existing && existing !==
|
|
9774
|
+
if (existing && existing !== element) {
|
|
9675
9775
|
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9676
9776
|
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9677
9777
|
}
|
|
9678
|
-
this.bindings.set(key,
|
|
9778
|
+
this.bindings.set(key, element);
|
|
9679
9779
|
};
|
|
9680
9780
|
/**
|
|
9681
9781
|
* Removes the audio element binding for the given session and track type.
|
|
@@ -9701,6 +9801,7 @@ class AudioBindingsWatchdog {
|
|
|
9701
9801
|
*/
|
|
9702
9802
|
this.dispose = () => {
|
|
9703
9803
|
this.stop();
|
|
9804
|
+
this.bindings.clear();
|
|
9704
9805
|
this.unsubscribeCallingState();
|
|
9705
9806
|
};
|
|
9706
9807
|
this.start = () => {
|
|
@@ -9732,6 +9833,8 @@ class AudioBindingsWatchdog {
|
|
|
9732
9833
|
this.stop = () => {
|
|
9733
9834
|
clearInterval(this.watchdogInterval);
|
|
9734
9835
|
};
|
|
9836
|
+
this.tracer = tracer;
|
|
9837
|
+
this.state = state;
|
|
9735
9838
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
9736
9839
|
if (!this.enabled)
|
|
9737
9840
|
return;
|
|
@@ -9745,64 +9848,100 @@ class AudioBindingsWatchdog {
|
|
|
9745
9848
|
}
|
|
9746
9849
|
}
|
|
9747
9850
|
|
|
9748
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
9749
|
-
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
9750
|
-
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
9751
|
-
};
|
|
9752
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9753
9851
|
/**
|
|
9754
|
-
*
|
|
9755
|
-
*
|
|
9756
|
-
* - binding video elements to session ids
|
|
9757
|
-
* - binding audio elements to session ids
|
|
9758
|
-
* - tracking element visibility
|
|
9759
|
-
* - updating subscriptions based on viewport visibility
|
|
9760
|
-
* - updating subscriptions based on video element dimensions
|
|
9761
|
-
* - updating subscriptions based on published tracks
|
|
9852
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9762
9853
|
*/
|
|
9763
|
-
class
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
constructor(callState, speaker, tracer) {
|
|
9854
|
+
class BlockedAudioTracker {
|
|
9855
|
+
constructor(tracer) {
|
|
9856
|
+
this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
9857
|
+
this.blockedElementsSubject = new rxjs.BehaviorSubject(new Set());
|
|
9768
9858
|
/**
|
|
9769
|
-
*
|
|
9859
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
9860
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
9861
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
9770
9862
|
*/
|
|
9771
|
-
this.
|
|
9772
|
-
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
9773
|
-
this.useWebAudio = false;
|
|
9774
|
-
this.pendingSubscriptionsUpdate = null;
|
|
9863
|
+
this.autoplayBlocked$ = this.blockedElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
|
|
9775
9864
|
/**
|
|
9776
|
-
*
|
|
9777
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
9865
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
9778
9866
|
*/
|
|
9779
|
-
this.
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
this.addBlockedAudioElement = (audioElement) => {
|
|
9787
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
9788
|
-
const next = new Set(elements);
|
|
9789
|
-
next.add(audioElement);
|
|
9790
|
-
return next;
|
|
9867
|
+
this.markBlocked = (audioElement, blocked) => {
|
|
9868
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
9869
|
+
if (blocked)
|
|
9870
|
+
elements.add(audioElement);
|
|
9871
|
+
else
|
|
9872
|
+
elements.delete(audioElement);
|
|
9873
|
+
return elements;
|
|
9791
9874
|
});
|
|
9792
9875
|
};
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9876
|
+
/**
|
|
9877
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
9878
|
+
* by the browser's autoplay policy.
|
|
9879
|
+
*/
|
|
9880
|
+
this.isBlocked = (audioElement) => {
|
|
9881
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
9882
|
+
};
|
|
9883
|
+
/**
|
|
9884
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
9885
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
9886
|
+
*/
|
|
9887
|
+
this.resumeAudio = async () => {
|
|
9888
|
+
this.tracer.trace('resumeAudio', null);
|
|
9889
|
+
await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
|
|
9890
|
+
await Promise.all(Array.from(elements, async (element) => {
|
|
9891
|
+
try {
|
|
9892
|
+
if (element.srcObject)
|
|
9893
|
+
await timeboxed([element.play()], 2000);
|
|
9894
|
+
elements.delete(element);
|
|
9895
|
+
}
|
|
9896
|
+
catch (err) {
|
|
9897
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
9898
|
+
}
|
|
9899
|
+
}));
|
|
9900
|
+
return elements;
|
|
9798
9901
|
});
|
|
9799
9902
|
};
|
|
9800
|
-
this.
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9903
|
+
this.tracer = tracer;
|
|
9904
|
+
}
|
|
9905
|
+
}
|
|
9906
|
+
|
|
9907
|
+
/** Symbol key for the "applies to all participants" override slot. */
|
|
9908
|
+
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9909
|
+
/**
|
|
9910
|
+
* Owns the SFU-side video-subscription machinery for a `Call`:
|
|
9911
|
+
*
|
|
9912
|
+
* - Holds the per-session / global override state in a
|
|
9913
|
+
* `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
|
|
9914
|
+
* - Derives the SFU subscription list from `CallState` participants +
|
|
9915
|
+
* current overrides via the `subscriptions` getter.
|
|
9916
|
+
* - Debounces and pushes the list to the SFU through
|
|
9917
|
+
* `sfuClient.updateSubscriptions`.
|
|
9918
|
+
* - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
|
|
9919
|
+
* the override state for React hooks.
|
|
9920
|
+
*
|
|
9921
|
+
* Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
|
|
9922
|
+
* `DynascaleManager.bindVideoElement` triggers `apply()` on every
|
|
9923
|
+
* dimension / visibility change.
|
|
9924
|
+
*/
|
|
9925
|
+
class TrackSubscriptionManager {
|
|
9926
|
+
/**
|
|
9927
|
+
* Constructs new TrackSubscriptionManager instance.
|
|
9928
|
+
*
|
|
9929
|
+
* @param callState the call state.
|
|
9930
|
+
* @param tracer the tracer to use.
|
|
9931
|
+
*/
|
|
9932
|
+
constructor(callState, tracer) {
|
|
9933
|
+
this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
|
|
9934
|
+
this.pendingUpdate = null;
|
|
9935
|
+
this.overridesSubject = new rxjs.BehaviorSubject({});
|
|
9936
|
+
this.overrides$ = this.overridesSubject.asObservable();
|
|
9937
|
+
/**
|
|
9938
|
+
* Consumer-friendly projection of the override state. Used by the
|
|
9939
|
+
* `useIncomingVideoSettings()` React hook.
|
|
9940
|
+
*/
|
|
9941
|
+
this.incomingVideoSettings$ = this.overrides$.pipe(rxjs.map((overrides) => {
|
|
9942
|
+
const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
|
|
9943
|
+
return {
|
|
9944
|
+
enabled: globalSettings?.enabled !== false,
|
|
9806
9945
|
preferredResolution: globalSettings?.enabled
|
|
9807
9946
|
? globalSettings.dimension
|
|
9808
9947
|
: undefined,
|
|
@@ -9821,106 +9960,255 @@ class DynascaleManager {
|
|
|
9821
9960
|
};
|
|
9822
9961
|
}), rxjs.shareReplay(1));
|
|
9823
9962
|
/**
|
|
9824
|
-
*
|
|
9963
|
+
* Sets the SFU client used by `apply()` to push subscription updates.
|
|
9964
|
+
* Called by the owner on call join; cleared on leave.
|
|
9825
9965
|
*/
|
|
9826
|
-
this.
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
9833
|
-
if (
|
|
9834
|
-
|
|
9835
|
-
|
|
9836
|
-
this.audioContext = undefined;
|
|
9966
|
+
this.setSfuClient = (sfuClient) => {
|
|
9967
|
+
this.sfuClient = sfuClient;
|
|
9968
|
+
};
|
|
9969
|
+
/**
|
|
9970
|
+
* Cancels any pending debounced subscription push. Idempotent.
|
|
9971
|
+
*/
|
|
9972
|
+
this.dispose = () => {
|
|
9973
|
+
if (this.pendingUpdate) {
|
|
9974
|
+
clearTimeout(this.pendingUpdate);
|
|
9975
|
+
this.pendingUpdate = null;
|
|
9837
9976
|
}
|
|
9838
9977
|
};
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
9843
|
-
|
|
9978
|
+
/**
|
|
9979
|
+
* Sets video-subscription overrides. Called by
|
|
9980
|
+
* `Call.setIncomingVideoEnabled` and
|
|
9981
|
+
* `Call.setPreferredIncomingVideoResolution`.
|
|
9982
|
+
*
|
|
9983
|
+
* - `sessionIds` omitted → applies `override` globally (or clears the
|
|
9984
|
+
* global override if `override` is `undefined`).
|
|
9985
|
+
* - `sessionIds` provided → applies `override` to each listed session.
|
|
9986
|
+
*/
|
|
9987
|
+
this.setOverrides = (override, sessionIds) => {
|
|
9988
|
+
this.tracer.trace('setOverrides', [override, sessionIds]);
|
|
9844
9989
|
if (!sessionIds) {
|
|
9845
|
-
return setCurrentValue(this.
|
|
9990
|
+
return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
|
|
9846
9991
|
}
|
|
9847
|
-
return setCurrentValue(this.
|
|
9992
|
+
return setCurrentValue(this.overridesSubject, (overrides) => ({
|
|
9848
9993
|
...overrides,
|
|
9849
9994
|
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
9850
9995
|
}));
|
|
9851
9996
|
};
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
9997
|
+
/**
|
|
9998
|
+
* Pushes `subscriptions` to the SFU. Debounced by `debounceType`
|
|
9999
|
+
* (SLOW by default). Multiple rapid calls coalesce into one RPC.
|
|
10000
|
+
* Passing `0` fires synchronously.
|
|
10001
|
+
*/
|
|
10002
|
+
this.apply = (debounceType = exports.DebounceType.SLOW) => {
|
|
10003
|
+
if (this.pendingUpdate) {
|
|
10004
|
+
clearTimeout(this.pendingUpdate);
|
|
9855
10005
|
}
|
|
9856
10006
|
const updateSubscriptions = () => {
|
|
9857
|
-
this.
|
|
10007
|
+
this.pendingUpdate = null;
|
|
9858
10008
|
this.sfuClient
|
|
9859
|
-
?.updateSubscriptions(this.
|
|
10009
|
+
?.updateSubscriptions(this.subscriptions)
|
|
9860
10010
|
.catch((err) => {
|
|
9861
10011
|
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
9862
10012
|
});
|
|
9863
10013
|
};
|
|
9864
10014
|
if (debounceType) {
|
|
9865
|
-
this.
|
|
10015
|
+
this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
|
|
9866
10016
|
}
|
|
9867
10017
|
else {
|
|
9868
10018
|
updateSubscriptions();
|
|
9869
10019
|
}
|
|
9870
10020
|
};
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
10021
|
+
this.tracer = tracer;
|
|
10022
|
+
this.callState = callState;
|
|
10023
|
+
}
|
|
10024
|
+
/**
|
|
10025
|
+
* The current SFU subscription list, computed from `CallState`
|
|
10026
|
+
* participants and the override state. Used by:
|
|
10027
|
+
*
|
|
10028
|
+
* - `apply()` to push to the SFU each time the set changes.
|
|
10029
|
+
* - `Call.getReconnectDetails` to include the subscription list in
|
|
10030
|
+
* the reconnect payload.
|
|
10031
|
+
*/
|
|
10032
|
+
get subscriptions() {
|
|
10033
|
+
const subscriptions = [];
|
|
10034
|
+
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
10035
|
+
// and avoid stale data caused by shareReplay with no active subscribers
|
|
10036
|
+
const participants = this.callState.getParticipantsSnapshot();
|
|
10037
|
+
const overrides = this.overridesSubject.getValue();
|
|
10038
|
+
for (const p of participants) {
|
|
10039
|
+
if (p.isLocalParticipant)
|
|
10040
|
+
continue;
|
|
10041
|
+
// NOTE: audio tracks don't have to be requested explicitly
|
|
10042
|
+
// as the SFU will implicitly subscribe us to all of them,
|
|
10043
|
+
// once they become available.
|
|
10044
|
+
if (p.videoDimension && hasVideo(p)) {
|
|
10045
|
+
const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
|
|
10046
|
+
if (override?.enabled !== false) {
|
|
10047
|
+
subscriptions.push({
|
|
10048
|
+
userId: p.userId,
|
|
10049
|
+
sessionId: p.sessionId,
|
|
10050
|
+
trackType: TrackType.VIDEO,
|
|
10051
|
+
dimension: override?.dimension ?? p.videoDimension,
|
|
10052
|
+
});
|
|
10053
|
+
}
|
|
10054
|
+
}
|
|
10055
|
+
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
10056
|
+
subscriptions.push({
|
|
10057
|
+
userId: p.userId,
|
|
10058
|
+
sessionId: p.sessionId,
|
|
10059
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10060
|
+
dimension: p.screenShareDimension,
|
|
9897
10061
|
});
|
|
10062
|
+
}
|
|
10063
|
+
if (hasScreenShareAudio(p)) {
|
|
10064
|
+
subscriptions.push({
|
|
10065
|
+
userId: p.userId,
|
|
10066
|
+
sessionId: p.sessionId,
|
|
10067
|
+
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
10068
|
+
});
|
|
10069
|
+
}
|
|
10070
|
+
}
|
|
10071
|
+
return subscriptions;
|
|
10072
|
+
}
|
|
10073
|
+
get overrides() {
|
|
10074
|
+
return getCurrentValue(this.overrides$);
|
|
10075
|
+
}
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
/**
|
|
10079
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
10080
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
10081
|
+
*/
|
|
10082
|
+
class MediaPlaybackWatchdog {
|
|
10083
|
+
constructor(opts) {
|
|
10084
|
+
this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
10085
|
+
this.controller = new AbortController();
|
|
10086
|
+
this.attempt = 0;
|
|
10087
|
+
this.disposed = false;
|
|
10088
|
+
this.attach = () => {
|
|
10089
|
+
if (this.disposed)
|
|
10090
|
+
return;
|
|
10091
|
+
const { signal } = this.controller;
|
|
10092
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
10093
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
10094
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
10095
|
+
};
|
|
10096
|
+
this.dispose = () => {
|
|
10097
|
+
if (this.disposed)
|
|
10098
|
+
return;
|
|
10099
|
+
this.disposed = true;
|
|
10100
|
+
this.controller.abort();
|
|
10101
|
+
if (this.pendingTimer)
|
|
10102
|
+
clearTimeout(this.pendingTimer);
|
|
10103
|
+
this.pendingTimer = undefined;
|
|
10104
|
+
};
|
|
10105
|
+
this.onPlaying = () => {
|
|
10106
|
+
if (this.attempt > 0) {
|
|
10107
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
10108
|
+
kind: this.kind,
|
|
10109
|
+
attempts: this.attempt,
|
|
10110
|
+
});
|
|
10111
|
+
}
|
|
10112
|
+
this.attempt = 0;
|
|
10113
|
+
if (this.pendingTimer)
|
|
10114
|
+
clearTimeout(this.pendingTimer);
|
|
10115
|
+
this.pendingTimer = undefined;
|
|
10116
|
+
};
|
|
10117
|
+
this.onPauseOrSuspend = (event) => {
|
|
10118
|
+
if (this.disposed)
|
|
10119
|
+
return;
|
|
10120
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
10121
|
+
kind: this.kind,
|
|
10122
|
+
reason: event.type,
|
|
9898
10123
|
});
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
viewportVisibilityState: {
|
|
9910
|
-
...previousVisibilityState,
|
|
9911
|
-
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
9912
|
-
},
|
|
9913
|
-
};
|
|
10124
|
+
this.scheduleRecovery();
|
|
10125
|
+
};
|
|
10126
|
+
this.scheduleRecovery = () => {
|
|
10127
|
+
if (this.disposed || this.pendingTimer)
|
|
10128
|
+
return;
|
|
10129
|
+
const skipReason = this.computeSkipReason();
|
|
10130
|
+
if (skipReason) {
|
|
10131
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
10132
|
+
kind: this.kind,
|
|
10133
|
+
reason: skipReason,
|
|
9914
10134
|
});
|
|
9915
|
-
|
|
10135
|
+
return;
|
|
10136
|
+
}
|
|
10137
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
10138
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
10139
|
+
};
|
|
10140
|
+
this.computeSkipReason = () => {
|
|
10141
|
+
if (this.disposed)
|
|
10142
|
+
return 'disposed';
|
|
10143
|
+
if (!this.element.srcObject)
|
|
10144
|
+
return 'noSrc';
|
|
10145
|
+
if (this.element.ended)
|
|
10146
|
+
return 'ended';
|
|
10147
|
+
if (this.isBlocked())
|
|
10148
|
+
return 'blocked';
|
|
10149
|
+
const HAVE_CURRENT_DATA = 2;
|
|
10150
|
+
if (this.element.readyState < HAVE_CURRENT_DATA)
|
|
10151
|
+
return 'notReady';
|
|
10152
|
+
if (!this.element.paused)
|
|
10153
|
+
return 'notPaused';
|
|
10154
|
+
};
|
|
10155
|
+
this.attemptPlay = async () => {
|
|
10156
|
+
this.pendingTimer = undefined;
|
|
10157
|
+
if (this.disposed)
|
|
10158
|
+
return;
|
|
10159
|
+
this.attempt += 1;
|
|
10160
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
10161
|
+
kind: this.kind,
|
|
10162
|
+
attempt: this.attempt,
|
|
10163
|
+
});
|
|
10164
|
+
try {
|
|
10165
|
+
await timeboxed([this.element.play()], 2000);
|
|
10166
|
+
}
|
|
10167
|
+
catch (err) {
|
|
10168
|
+
if (this.disposed)
|
|
10169
|
+
return;
|
|
10170
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
10171
|
+
if (this.attempt >= 10) {
|
|
10172
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
10173
|
+
kind: this.kind,
|
|
10174
|
+
attempts: this.attempt,
|
|
10175
|
+
});
|
|
10176
|
+
return;
|
|
10177
|
+
}
|
|
10178
|
+
this.scheduleRecovery();
|
|
10179
|
+
}
|
|
9916
10180
|
};
|
|
10181
|
+
this.element = opts.element;
|
|
10182
|
+
this.kind = opts.kind;
|
|
10183
|
+
this.tracer = opts.tracer;
|
|
10184
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
10185
|
+
this.attach();
|
|
10186
|
+
}
|
|
10187
|
+
}
|
|
10188
|
+
|
|
10189
|
+
/**
|
|
10190
|
+
* A manager class that handles dynascale related tasks like:
|
|
10191
|
+
*
|
|
10192
|
+
* - binding video elements to session ids
|
|
10193
|
+
* - binding audio elements to session ids
|
|
10194
|
+
*/
|
|
10195
|
+
class DynascaleManager {
|
|
10196
|
+
/**
|
|
10197
|
+
* Creates a new DynascaleManager instance.
|
|
10198
|
+
*/
|
|
10199
|
+
constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
|
|
10200
|
+
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
10201
|
+
this.useWebAudio = false;
|
|
9917
10202
|
/**
|
|
9918
|
-
*
|
|
9919
|
-
*
|
|
9920
|
-
* @param element the viewport element.
|
|
10203
|
+
* Closes the audio context if it was created.
|
|
9921
10204
|
*/
|
|
9922
|
-
this.
|
|
9923
|
-
|
|
10205
|
+
this.dispose = async () => {
|
|
10206
|
+
const context = this.audioContext;
|
|
10207
|
+
if (context && context.state !== 'closed') {
|
|
10208
|
+
document.removeEventListener('click', this.resumeAudioContext);
|
|
10209
|
+
await context.close();
|
|
10210
|
+
this.audioContext = undefined;
|
|
10211
|
+
}
|
|
9924
10212
|
};
|
|
9925
10213
|
/**
|
|
9926
10214
|
* Sets whether to use WebAudio API for audio playback.
|
|
@@ -9965,7 +10253,7 @@ class DynascaleManager {
|
|
|
9965
10253
|
this.callState.updateParticipantTracks(trackType, {
|
|
9966
10254
|
[sessionId]: { dimension },
|
|
9967
10255
|
});
|
|
9968
|
-
this.
|
|
10256
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
9969
10257
|
};
|
|
9970
10258
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((participant) => !!participant), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
9971
10259
|
/**
|
|
@@ -10054,6 +10342,11 @@ class DynascaleManager {
|
|
|
10054
10342
|
// without prior user interaction:
|
|
10055
10343
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
10056
10344
|
videoElement.muted = true;
|
|
10345
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
10346
|
+
element: videoElement,
|
|
10347
|
+
kind: 'video',
|
|
10348
|
+
tracer: this.tracer,
|
|
10349
|
+
});
|
|
10057
10350
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
10058
10351
|
const streamSubscription = participant$
|
|
10059
10352
|
.pipe(rxjs.distinctUntilKeyChanged(trackKey))
|
|
@@ -10063,14 +10356,14 @@ class DynascaleManager {
|
|
|
10063
10356
|
return;
|
|
10064
10357
|
videoElement.srcObject = source ?? null;
|
|
10065
10358
|
if (isSafari() || isFirefox()) {
|
|
10066
|
-
setTimeout(() => {
|
|
10359
|
+
setTimeout(async () => {
|
|
10067
10360
|
videoElement.srcObject = source ?? null;
|
|
10068
|
-
|
|
10361
|
+
try {
|
|
10362
|
+
await timeboxed([videoElement.play()], 2000);
|
|
10363
|
+
}
|
|
10364
|
+
catch (e) {
|
|
10069
10365
|
this.logger.warn(`Failed to play stream`, e);
|
|
10070
|
-
}
|
|
10071
|
-
// we add extra delay until we attempt to force-play
|
|
10072
|
-
// the participant's media stream in Firefox and Safari,
|
|
10073
|
-
// as they seem to have some timing issues
|
|
10366
|
+
}
|
|
10074
10367
|
}, 25);
|
|
10075
10368
|
}
|
|
10076
10369
|
});
|
|
@@ -10080,6 +10373,7 @@ class DynascaleManager {
|
|
|
10080
10373
|
publishedTracksSubscription?.unsubscribe();
|
|
10081
10374
|
streamSubscription.unsubscribe();
|
|
10082
10375
|
resizeObserver?.disconnect();
|
|
10376
|
+
playbackWatchdog.dispose();
|
|
10083
10377
|
};
|
|
10084
10378
|
};
|
|
10085
10379
|
/**
|
|
@@ -10097,7 +10391,6 @@ class DynascaleManager {
|
|
|
10097
10391
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
10098
10392
|
if (!participant || participant.isLocalParticipant)
|
|
10099
10393
|
return;
|
|
10100
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
10101
10394
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
10102
10395
|
const updateSinkId = (deviceId, audioContext) => {
|
|
10103
10396
|
if (!deviceId)
|
|
@@ -10116,6 +10409,7 @@ class DynascaleManager {
|
|
|
10116
10409
|
};
|
|
10117
10410
|
let sourceNode = undefined;
|
|
10118
10411
|
let gainNode = undefined;
|
|
10412
|
+
let audioWatchdog = undefined;
|
|
10119
10413
|
const isAudioTrack = trackType === 'audioTrack';
|
|
10120
10414
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
10121
10415
|
const updateMediaStreamSubscription = participant$
|
|
@@ -10126,8 +10420,10 @@ class DynascaleManager {
|
|
|
10126
10420
|
return;
|
|
10127
10421
|
setTimeout(() => {
|
|
10128
10422
|
audioElement.srcObject = source ?? null;
|
|
10423
|
+
audioWatchdog?.dispose();
|
|
10424
|
+
audioWatchdog = undefined;
|
|
10129
10425
|
if (!source) {
|
|
10130
|
-
this.
|
|
10426
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10131
10427
|
return;
|
|
10132
10428
|
}
|
|
10133
10429
|
// Safari has a special quirk that prevents playing audio until the user
|
|
@@ -10155,10 +10451,16 @@ class DynascaleManager {
|
|
|
10155
10451
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
10156
10452
|
if (e.name === 'NotAllowedError') {
|
|
10157
10453
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
10158
|
-
this.
|
|
10454
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
10159
10455
|
}
|
|
10160
10456
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
10161
10457
|
});
|
|
10458
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
10459
|
+
element: audioElement,
|
|
10460
|
+
kind: 'audio',
|
|
10461
|
+
tracer: this.tracer,
|
|
10462
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
10463
|
+
});
|
|
10162
10464
|
}
|
|
10163
10465
|
const { selectedDevice } = this.speaker.state;
|
|
10164
10466
|
if (selectedDevice)
|
|
@@ -10182,38 +10484,17 @@ class DynascaleManager {
|
|
|
10182
10484
|
});
|
|
10183
10485
|
audioElement.autoplay = true;
|
|
10184
10486
|
return () => {
|
|
10185
|
-
this.
|
|
10186
|
-
this.removeBlockedAudioElement(audioElement);
|
|
10487
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10187
10488
|
sinkIdSubscription?.unsubscribe();
|
|
10188
10489
|
volumeSubscription.unsubscribe();
|
|
10189
10490
|
updateMediaStreamSubscription.unsubscribe();
|
|
10190
10491
|
audioElement.srcObject = null;
|
|
10191
10492
|
sourceNode?.disconnect();
|
|
10192
10493
|
gainNode?.disconnect();
|
|
10494
|
+
audioWatchdog?.dispose();
|
|
10495
|
+
audioWatchdog = undefined;
|
|
10193
10496
|
};
|
|
10194
10497
|
};
|
|
10195
|
-
/**
|
|
10196
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10197
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
10198
|
-
*
|
|
10199
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
10200
|
-
*/
|
|
10201
|
-
this.resumeAudio = async () => {
|
|
10202
|
-
this.tracer.trace('resumeAudio', null);
|
|
10203
|
-
const blocked = new Set();
|
|
10204
|
-
await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
|
|
10205
|
-
try {
|
|
10206
|
-
if (el.srcObject) {
|
|
10207
|
-
await el.play();
|
|
10208
|
-
}
|
|
10209
|
-
}
|
|
10210
|
-
catch {
|
|
10211
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
10212
|
-
blocked.add(el);
|
|
10213
|
-
}
|
|
10214
|
-
}));
|
|
10215
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
10216
|
-
};
|
|
10217
10498
|
this.getOrCreateAudioContext = () => {
|
|
10218
10499
|
if (!this.useWebAudio)
|
|
10219
10500
|
return;
|
|
@@ -10266,57 +10547,124 @@ class DynascaleManager {
|
|
|
10266
10547
|
this.callState = callState;
|
|
10267
10548
|
this.speaker = speaker;
|
|
10268
10549
|
this.tracer = tracer;
|
|
10269
|
-
|
|
10270
|
-
|
|
10271
|
-
}
|
|
10272
|
-
}
|
|
10273
|
-
setSfuClient(sfuClient) {
|
|
10274
|
-
this.sfuClient = sfuClient;
|
|
10550
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
10551
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
10275
10552
|
}
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
10553
|
+
}
|
|
10554
|
+
|
|
10555
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
10556
|
+
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
10557
|
+
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
10558
|
+
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
10559
|
+
};
|
|
10560
|
+
class ViewportTracker {
|
|
10561
|
+
constructor(callState) {
|
|
10562
|
+
this.elementHandlerMap = new Map();
|
|
10563
|
+
this.observer = null;
|
|
10564
|
+
// in React children render before viewport is set, add
|
|
10565
|
+
// them to the queue and observe them once the observer is ready
|
|
10566
|
+
this.queueSet = new Set();
|
|
10567
|
+
/**
|
|
10568
|
+
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
10569
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10570
|
+
*/
|
|
10571
|
+
this.setViewport = (viewportElement, options) => {
|
|
10572
|
+
const cleanup = () => {
|
|
10573
|
+
this.observer?.disconnect();
|
|
10574
|
+
this.observer = null;
|
|
10575
|
+
this.elementHandlerMap.clear();
|
|
10576
|
+
};
|
|
10577
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
10578
|
+
entries.forEach((entry) => {
|
|
10579
|
+
const handler = this.elementHandlerMap.get(entry.target);
|
|
10580
|
+
handler?.(entry);
|
|
10581
|
+
});
|
|
10582
|
+
}, {
|
|
10583
|
+
root: viewportElement,
|
|
10584
|
+
...options,
|
|
10585
|
+
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
10586
|
+
});
|
|
10587
|
+
if (this.queueSet.size) {
|
|
10588
|
+
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
10589
|
+
// check if element which requested observation is
|
|
10590
|
+
// a child of a viewport element, skip if isn't
|
|
10591
|
+
if (!viewportElement.contains(queueElement))
|
|
10592
|
+
return;
|
|
10593
|
+
this.observer.observe(queueElement);
|
|
10594
|
+
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
10595
|
+
});
|
|
10596
|
+
this.queueSet.clear();
|
|
10299
10597
|
}
|
|
10300
|
-
|
|
10301
|
-
|
|
10302
|
-
|
|
10303
|
-
|
|
10304
|
-
|
|
10305
|
-
|
|
10598
|
+
return cleanup;
|
|
10599
|
+
};
|
|
10600
|
+
/**
|
|
10601
|
+
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
10602
|
+
* detects a possible change in element's visibility within specified viewport, returns
|
|
10603
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10604
|
+
*/
|
|
10605
|
+
this.observe = (element, handler) => {
|
|
10606
|
+
const queueItem = [element, handler];
|
|
10607
|
+
const cleanup = () => {
|
|
10608
|
+
this.elementHandlerMap.delete(element);
|
|
10609
|
+
this.observer?.unobserve(element);
|
|
10610
|
+
this.queueSet.delete(queueItem);
|
|
10611
|
+
};
|
|
10612
|
+
if (this.elementHandlerMap.has(element))
|
|
10613
|
+
return cleanup;
|
|
10614
|
+
if (!this.observer) {
|
|
10615
|
+
this.queueSet.add(queueItem);
|
|
10616
|
+
return cleanup;
|
|
10617
|
+
}
|
|
10618
|
+
if (this.observer.root.contains(element)) {
|
|
10619
|
+
this.elementHandlerMap.set(element, handler);
|
|
10620
|
+
this.observer.observe(element);
|
|
10621
|
+
}
|
|
10622
|
+
return cleanup;
|
|
10623
|
+
};
|
|
10624
|
+
/**
|
|
10625
|
+
* Tracks the given element for visibility changes and mirrors the result
|
|
10626
|
+
* into `participant.viewportVisibilityState[trackType]` in `CallState`.
|
|
10627
|
+
* Returns a function that unobserves the element and resets the visibility
|
|
10628
|
+
* state back to `UNKNOWN`.
|
|
10629
|
+
*/
|
|
10630
|
+
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
10631
|
+
const cleanup = this.observe(element, (entry) => {
|
|
10632
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10633
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10634
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10635
|
+
// observer triggers when the element is "moved" to be a fullscreen element
|
|
10636
|
+
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
10637
|
+
const isVisible = entry.isIntersecting || document.fullscreenElement === element
|
|
10638
|
+
? exports.VisibilityState.VISIBLE
|
|
10639
|
+
: exports.VisibilityState.INVISIBLE;
|
|
10640
|
+
return {
|
|
10641
|
+
...participant,
|
|
10642
|
+
viewportVisibilityState: {
|
|
10643
|
+
...previousVisibilityState,
|
|
10644
|
+
[trackType]: isVisible,
|
|
10645
|
+
},
|
|
10646
|
+
};
|
|
10306
10647
|
});
|
|
10307
|
-
}
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
10648
|
+
});
|
|
10649
|
+
return () => {
|
|
10650
|
+
cleanup();
|
|
10651
|
+
// reset visibility state to UNKNOWN upon cleanup
|
|
10652
|
+
// so that the layouts that are not actively observed
|
|
10653
|
+
// can still function normally (runtime layout switching)
|
|
10654
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10655
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10656
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10657
|
+
return {
|
|
10658
|
+
...participant,
|
|
10659
|
+
viewportVisibilityState: {
|
|
10660
|
+
...previousVisibilityState,
|
|
10661
|
+
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
10662
|
+
},
|
|
10663
|
+
};
|
|
10313
10664
|
});
|
|
10314
|
-
}
|
|
10315
|
-
}
|
|
10316
|
-
|
|
10317
|
-
}
|
|
10318
|
-
get videoTrackSubscriptionOverrides() {
|
|
10319
|
-
return getCurrentValue(this.videoTrackSubscriptionOverrides$);
|
|
10665
|
+
};
|
|
10666
|
+
};
|
|
10667
|
+
this.callState = callState;
|
|
10320
10668
|
}
|
|
10321
10669
|
}
|
|
10322
10670
|
|
|
@@ -11034,6 +11382,7 @@ class DeviceManager {
|
|
|
11034
11382
|
*/
|
|
11035
11383
|
this.stopOnLeave = true;
|
|
11036
11384
|
this.subscriptions = [];
|
|
11385
|
+
this.currentStreamCleanups = [];
|
|
11037
11386
|
this.areSubscriptionsSetUp = false;
|
|
11038
11387
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
11039
11388
|
this.filters = [];
|
|
@@ -11045,10 +11394,30 @@ class DeviceManager {
|
|
|
11045
11394
|
* @internal
|
|
11046
11395
|
*/
|
|
11047
11396
|
this.dispose = () => {
|
|
11397
|
+
this.runCurrentStreamCleanups();
|
|
11048
11398
|
this.subscriptions.forEach((s) => s());
|
|
11049
11399
|
this.subscriptions = [];
|
|
11050
11400
|
this.areSubscriptionsSetUp = false;
|
|
11051
11401
|
};
|
|
11402
|
+
this.runCurrentStreamCleanups = () => {
|
|
11403
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
11404
|
+
this.currentStreamCleanups = [];
|
|
11405
|
+
};
|
|
11406
|
+
this.setLocalInterrupted = (interrupted) => {
|
|
11407
|
+
const localParticipant = this.call.state.localParticipant;
|
|
11408
|
+
if (!localParticipant)
|
|
11409
|
+
return;
|
|
11410
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
11411
|
+
const current = p.interruptedTracks ?? [];
|
|
11412
|
+
const has = current.includes(this.trackType);
|
|
11413
|
+
if (interrupted === has)
|
|
11414
|
+
return {};
|
|
11415
|
+
const next = interrupted
|
|
11416
|
+
? pushToIfMissing([...current], this.trackType)
|
|
11417
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
11418
|
+
return { interruptedTracks: next };
|
|
11419
|
+
});
|
|
11420
|
+
};
|
|
11052
11421
|
this.call = call;
|
|
11053
11422
|
this.state = state;
|
|
11054
11423
|
this.trackType = trackType;
|
|
@@ -11272,7 +11641,9 @@ class DeviceManager {
|
|
|
11272
11641
|
// @ts-expect-error called to dispose the stream in RN
|
|
11273
11642
|
mediaStream.release();
|
|
11274
11643
|
}
|
|
11644
|
+
this.runCurrentStreamCleanups();
|
|
11275
11645
|
this.state.setMediaStream(undefined, undefined);
|
|
11646
|
+
this.setLocalInterrupted(false);
|
|
11276
11647
|
this.filters.forEach((entry) => entry.stop?.());
|
|
11277
11648
|
}
|
|
11278
11649
|
}
|
|
@@ -11308,13 +11679,17 @@ class DeviceManager {
|
|
|
11308
11679
|
async unmuteStream() {
|
|
11309
11680
|
this.logger.debug('Starting stream');
|
|
11310
11681
|
let stream;
|
|
11311
|
-
let
|
|
11682
|
+
let rootStreamPromise;
|
|
11312
11683
|
if (this.state.mediaStream &&
|
|
11313
11684
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
11314
11685
|
stream = this.state.mediaStream;
|
|
11315
11686
|
this.enableTracks();
|
|
11316
11687
|
}
|
|
11317
11688
|
else {
|
|
11689
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
11690
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
11691
|
+
// before chainWith below registers new ones for the new chain.
|
|
11692
|
+
this.runCurrentStreamCleanups();
|
|
11318
11693
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11319
11694
|
const constraints = {
|
|
11320
11695
|
...defaultConstraints,
|
|
@@ -11370,7 +11745,7 @@ class DeviceManager {
|
|
|
11370
11745
|
});
|
|
11371
11746
|
};
|
|
11372
11747
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
11373
|
-
this.
|
|
11748
|
+
this.currentStreamCleanups.push(() => {
|
|
11374
11749
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
11375
11750
|
});
|
|
11376
11751
|
});
|
|
@@ -11378,7 +11753,7 @@ class DeviceManager {
|
|
|
11378
11753
|
};
|
|
11379
11754
|
// the rootStream represents the stream coming from the actual device
|
|
11380
11755
|
// e.g. camera or microphone stream
|
|
11381
|
-
|
|
11756
|
+
rootStreamPromise = this.getStream(constraints);
|
|
11382
11757
|
// we publish the last MediaStream of the chain
|
|
11383
11758
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11384
11759
|
.then((inputStream) => {
|
|
@@ -11389,42 +11764,70 @@ class DeviceManager {
|
|
|
11389
11764
|
.then(chainWith(parent), (error) => {
|
|
11390
11765
|
this.logger.warn('Filter failed to start and will be ignored', error);
|
|
11391
11766
|
return parent;
|
|
11392
|
-
}),
|
|
11767
|
+
}), rootStreamPromise);
|
|
11393
11768
|
}
|
|
11394
11769
|
if (this.call.state.callingState === exports.CallingState.JOINED) {
|
|
11395
11770
|
await this.publishStream(stream);
|
|
11396
11771
|
}
|
|
11397
11772
|
if (this.state.mediaStream !== stream) {
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
this.
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11407
|
-
|
|
11408
|
-
|
|
11409
|
-
|
|
11410
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
this.
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
11418
|
-
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11773
|
+
const rootStream = await rootStreamPromise;
|
|
11774
|
+
this.state.setMediaStream(stream, rootStream);
|
|
11775
|
+
if (rootStream) {
|
|
11776
|
+
const handleTrackEnded = async () => {
|
|
11777
|
+
this.setLocalInterrupted(false);
|
|
11778
|
+
await this.statusChangeSettled();
|
|
11779
|
+
if (this.enabled) {
|
|
11780
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
11781
|
+
setTimeout(() => {
|
|
11782
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
11783
|
+
}, 2000);
|
|
11784
|
+
await this.disable();
|
|
11785
|
+
}
|
|
11786
|
+
};
|
|
11787
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
11788
|
+
this.setLocalInterrupted(muted);
|
|
11789
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
11790
|
+
// macOS audio session interruption even though the track is
|
|
11791
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
11792
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
11793
|
+
// page is hidden because the encoder won't resume until
|
|
11794
|
+
// foreground anyway.
|
|
11795
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
11796
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
11797
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
11798
|
+
});
|
|
11799
|
+
}
|
|
11800
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
11801
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
11802
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
11803
|
+
trackType: TrackType[this.trackType],
|
|
11804
|
+
muted,
|
|
11805
|
+
});
|
|
11806
|
+
this.call
|
|
11807
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
11808
|
+
.catch((err) => {
|
|
11809
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
11810
|
+
});
|
|
11811
|
+
}
|
|
11812
|
+
};
|
|
11813
|
+
rootStream.getTracks().forEach((track) => {
|
|
11814
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
11815
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
11816
|
+
track.addEventListener('mute', muteHandler);
|
|
11817
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
11818
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
11819
|
+
this.currentStreamCleanups.push(() => {
|
|
11820
|
+
track.removeEventListener('mute', muteHandler);
|
|
11821
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
11822
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
11823
|
+
});
|
|
11426
11824
|
});
|
|
11427
|
-
|
|
11825
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
11826
|
+
this.setLocalInterrupted(initialMuted);
|
|
11827
|
+
}
|
|
11828
|
+
else {
|
|
11829
|
+
this.setLocalInterrupted(false);
|
|
11830
|
+
}
|
|
11428
11831
|
}
|
|
11429
11832
|
}
|
|
11430
11833
|
get mediaDeviceKind() {
|
|
@@ -11570,7 +11973,6 @@ class DeviceManagerState {
|
|
|
11570
11973
|
this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
11571
11974
|
/**
|
|
11572
11975
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
11573
|
-
*
|
|
11574
11976
|
*/
|
|
11575
11977
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11576
11978
|
/**
|
|
@@ -13225,8 +13627,10 @@ class Call {
|
|
|
13225
13627
|
this.publisher = undefined;
|
|
13226
13628
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13227
13629
|
this.sfuClient = undefined;
|
|
13228
|
-
this.
|
|
13229
|
-
|
|
13630
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
13631
|
+
this.trackSubscriptionManager.dispose();
|
|
13632
|
+
this.audioBindingsWatchdog?.dispose();
|
|
13633
|
+
await this.dynascaleManager?.dispose();
|
|
13230
13634
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
13231
13635
|
this.state.setParticipants([]);
|
|
13232
13636
|
this.state.dispose();
|
|
@@ -13536,7 +13940,7 @@ class Call {
|
|
|
13536
13940
|
: previousSfuClient;
|
|
13537
13941
|
this.sfuClient = sfuClient;
|
|
13538
13942
|
this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
|
|
13539
|
-
this.
|
|
13943
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
13540
13944
|
const clientDetails = await getClientDetails();
|
|
13541
13945
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
13542
13946
|
if (previousSfuClient !== sfuClient) {
|
|
@@ -13671,7 +14075,7 @@ class Call {
|
|
|
13671
14075
|
return {
|
|
13672
14076
|
strategy,
|
|
13673
14077
|
announcedTracks,
|
|
13674
|
-
subscriptions: this.
|
|
14078
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
13675
14079
|
reconnectAttempt: this.reconnectAttempts,
|
|
13676
14080
|
fromSfuId: migratingFromSfuId || '',
|
|
13677
14081
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -14218,7 +14622,7 @@ class Call {
|
|
|
14218
14622
|
const { remoteParticipants } = this.state;
|
|
14219
14623
|
if (remoteParticipants.length <= 0)
|
|
14220
14624
|
return;
|
|
14221
|
-
this.
|
|
14625
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
14222
14626
|
};
|
|
14223
14627
|
/**
|
|
14224
14628
|
* Starts publishing the given video stream to the call.
|
|
@@ -14318,6 +14722,20 @@ class Call {
|
|
|
14318
14722
|
}));
|
|
14319
14723
|
}
|
|
14320
14724
|
};
|
|
14725
|
+
/**
|
|
14726
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
14727
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
14728
|
+
* interruption (Siri, PSTN call).
|
|
14729
|
+
*
|
|
14730
|
+
* @internal
|
|
14731
|
+
*
|
|
14732
|
+
* @param trackType the track type to refresh.
|
|
14733
|
+
*/
|
|
14734
|
+
this.refreshPublishedTrack = async (trackType) => {
|
|
14735
|
+
if (!this.publisher)
|
|
14736
|
+
return;
|
|
14737
|
+
await this.publisher.refreshTrack(trackType);
|
|
14738
|
+
};
|
|
14321
14739
|
/**
|
|
14322
14740
|
* Updates the preferred publishing options
|
|
14323
14741
|
*
|
|
@@ -14979,7 +15397,7 @@ class Call {
|
|
|
14979
15397
|
* @param trackType the video mode.
|
|
14980
15398
|
*/
|
|
14981
15399
|
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
14982
|
-
return this.
|
|
15400
|
+
return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
|
|
14983
15401
|
};
|
|
14984
15402
|
/**
|
|
14985
15403
|
* Sets the viewport element to track bound video elements for visibility.
|
|
@@ -14987,7 +15405,7 @@ class Call {
|
|
|
14987
15405
|
* @param element the viewport element.
|
|
14988
15406
|
*/
|
|
14989
15407
|
this.setViewport = (element) => {
|
|
14990
|
-
return this.
|
|
15408
|
+
return this.viewportTracker?.setViewport(element);
|
|
14991
15409
|
};
|
|
14992
15410
|
/**
|
|
14993
15411
|
* Binds a DOM <video> element to the given session id.
|
|
@@ -15005,7 +15423,7 @@ class Call {
|
|
|
15005
15423
|
* @param trackType the kind of video.
|
|
15006
15424
|
*/
|
|
15007
15425
|
this.bindVideoElement = (videoElement, sessionId, trackType) => {
|
|
15008
|
-
const unbind = this.dynascaleManager
|
|
15426
|
+
const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
|
|
15009
15427
|
if (!unbind)
|
|
15010
15428
|
return;
|
|
15011
15429
|
this.leaveCallHooks.add(unbind);
|
|
@@ -15025,21 +15443,28 @@ class Call {
|
|
|
15025
15443
|
* @param trackType the kind of audio.
|
|
15026
15444
|
*/
|
|
15027
15445
|
this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
|
|
15028
|
-
const unbind = this.dynascaleManager
|
|
15446
|
+
const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
|
|
15029
15447
|
if (!unbind)
|
|
15030
15448
|
return;
|
|
15031
|
-
this.
|
|
15032
|
-
|
|
15033
|
-
this.leaveCallHooks.delete(unbind);
|
|
15449
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
15450
|
+
const cleanup = () => {
|
|
15034
15451
|
unbind();
|
|
15452
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
15453
|
+
};
|
|
15454
|
+
this.leaveCallHooks.add(cleanup);
|
|
15455
|
+
return () => {
|
|
15456
|
+
this.leaveCallHooks.delete(cleanup);
|
|
15457
|
+
cleanup();
|
|
15035
15458
|
};
|
|
15036
15459
|
};
|
|
15037
15460
|
/**
|
|
15038
15461
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
15462
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
15463
|
+
*
|
|
15464
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
15465
|
+
* gesture is required.
|
|
15039
15466
|
*/
|
|
15040
|
-
this.resumeAudio = () =>
|
|
15041
|
-
return this.dynascaleManager.resumeAudio();
|
|
15042
|
-
};
|
|
15467
|
+
this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
15043
15468
|
/**
|
|
15044
15469
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
15045
15470
|
*
|
|
@@ -15077,21 +15502,21 @@ class Call {
|
|
|
15077
15502
|
* preference has effect on. Affects all participants by default.
|
|
15078
15503
|
*/
|
|
15079
15504
|
this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
|
|
15080
|
-
this.
|
|
15505
|
+
this.trackSubscriptionManager.setOverrides(resolution
|
|
15081
15506
|
? {
|
|
15082
15507
|
enabled: true,
|
|
15083
15508
|
dimension: resolution,
|
|
15084
15509
|
}
|
|
15085
15510
|
: undefined, sessionIds);
|
|
15086
|
-
this.
|
|
15511
|
+
this.trackSubscriptionManager.apply();
|
|
15087
15512
|
};
|
|
15088
15513
|
/**
|
|
15089
15514
|
* Enables or disables incoming video from all remote call participants,
|
|
15090
15515
|
* and removes any preference for preferred resolution.
|
|
15091
15516
|
*/
|
|
15092
15517
|
this.setIncomingVideoEnabled = (enabled) => {
|
|
15093
|
-
this.
|
|
15094
|
-
this.
|
|
15518
|
+
this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
|
|
15519
|
+
this.trackSubscriptionManager.apply();
|
|
15095
15520
|
};
|
|
15096
15521
|
/**
|
|
15097
15522
|
* Sets the maximum amount of time a user can remain waiting for a reconnect
|
|
@@ -15172,7 +15597,13 @@ class Call {
|
|
|
15172
15597
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
15173
15598
|
this.speaker = new SpeakerManager(this, preferences);
|
|
15174
15599
|
this.screenShare = new ScreenShareManager(this);
|
|
15175
|
-
this.
|
|
15600
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
|
|
15601
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
15602
|
+
if (typeof document !== 'undefined') {
|
|
15603
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
|
|
15604
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
15605
|
+
this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
|
|
15606
|
+
}
|
|
15176
15607
|
}
|
|
15177
15608
|
/**
|
|
15178
15609
|
* A flag indicating whether the call is "ringing" type of call.
|
|
@@ -15245,12 +15676,118 @@ const APIErrorCodes = {
|
|
|
15245
15676
|
*/
|
|
15246
15677
|
class StableWSConnection {
|
|
15247
15678
|
constructor(client) {
|
|
15679
|
+
/** Incremented when a new WS connection is made */
|
|
15680
|
+
this.wsID = 1;
|
|
15681
|
+
// Connection lifecycle flags.
|
|
15682
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15683
|
+
this.isConnecting = false;
|
|
15684
|
+
/** To avoid reconnect if client is disconnected */
|
|
15685
|
+
this.isDisconnected = false;
|
|
15686
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
15687
|
+
this.isHealthy = false;
|
|
15688
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
15689
|
+
this.isConnectionOpenResolved = false;
|
|
15690
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
15691
|
+
/** consecutive failures influence the duration of the timeout */
|
|
15692
|
+
this.consecutiveFailures = 0;
|
|
15693
|
+
/** keep track of the total number of failures */
|
|
15694
|
+
this.totalFailures = 0;
|
|
15695
|
+
// Health-check pings + connection-staleness check.
|
|
15696
|
+
/** Send a health check message every 25 seconds */
|
|
15697
|
+
this.pingInterval = 25 * 1000;
|
|
15698
|
+
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15699
|
+
/** Store the last event time for health checks */
|
|
15700
|
+
this.lastEvent = null;
|
|
15248
15701
|
this._log = (msg, extra = {}, level = 'info') => {
|
|
15249
15702
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
15250
15703
|
};
|
|
15251
15704
|
this.setClient = (client) => {
|
|
15252
15705
|
this.client = client;
|
|
15253
15706
|
};
|
|
15707
|
+
/**
|
|
15708
|
+
* connect - Connect to the WS URL
|
|
15709
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15710
|
+
* @return Promise that completes once the first health check message is received
|
|
15711
|
+
*/
|
|
15712
|
+
this.connect = async (timeout = 15000) => {
|
|
15713
|
+
if (this.isConnecting) {
|
|
15714
|
+
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15715
|
+
}
|
|
15716
|
+
this.isDisconnected = false;
|
|
15717
|
+
try {
|
|
15718
|
+
const healthCheck = await this._connect(timeout);
|
|
15719
|
+
this.consecutiveFailures = 0;
|
|
15720
|
+
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15721
|
+
}
|
|
15722
|
+
catch (caught) {
|
|
15723
|
+
const error = caught;
|
|
15724
|
+
this.isHealthy = false;
|
|
15725
|
+
this.consecutiveFailures += 1;
|
|
15726
|
+
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15727
|
+
!this.client.tokenManager.isStatic()) {
|
|
15728
|
+
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15729
|
+
this._reconnect({ refreshToken: true });
|
|
15730
|
+
}
|
|
15731
|
+
else if (!error.isWSFailure) {
|
|
15732
|
+
// API rejected the connection and we should not retry
|
|
15733
|
+
throw new Error(JSON.stringify({
|
|
15734
|
+
code: error.code,
|
|
15735
|
+
StatusCode: error.StatusCode,
|
|
15736
|
+
message: error.message,
|
|
15737
|
+
isWSFailure: error.isWSFailure,
|
|
15738
|
+
}));
|
|
15739
|
+
}
|
|
15740
|
+
else {
|
|
15741
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
15742
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
15743
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
15744
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
15745
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
15746
|
+
// own catch when _connect was called from there.
|
|
15747
|
+
this._reconnect();
|
|
15748
|
+
}
|
|
15749
|
+
}
|
|
15750
|
+
return await this._waitForHealthy(timeout);
|
|
15751
|
+
};
|
|
15752
|
+
/**
|
|
15753
|
+
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15754
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15755
|
+
* @param timeout duration(ms)
|
|
15756
|
+
*/
|
|
15757
|
+
this._waitForHealthy = async (timeout = 15000) => {
|
|
15758
|
+
return Promise.race([
|
|
15759
|
+
(async () => {
|
|
15760
|
+
const interval = 50; // ms
|
|
15761
|
+
for (let i = 0; i <= timeout; i += interval) {
|
|
15762
|
+
try {
|
|
15763
|
+
return await this.connectionOpen;
|
|
15764
|
+
}
|
|
15765
|
+
catch (caught) {
|
|
15766
|
+
const error = caught;
|
|
15767
|
+
if (i === timeout) {
|
|
15768
|
+
throw new Error(JSON.stringify({
|
|
15769
|
+
code: error.code,
|
|
15770
|
+
StatusCode: error.StatusCode,
|
|
15771
|
+
message: error.message,
|
|
15772
|
+
isWSFailure: error.isWSFailure,
|
|
15773
|
+
}));
|
|
15774
|
+
}
|
|
15775
|
+
await sleep(interval);
|
|
15776
|
+
}
|
|
15777
|
+
}
|
|
15778
|
+
})(),
|
|
15779
|
+
(async () => {
|
|
15780
|
+
await sleep(timeout);
|
|
15781
|
+
this.isConnecting = false;
|
|
15782
|
+
throw new Error(JSON.stringify({
|
|
15783
|
+
code: '',
|
|
15784
|
+
StatusCode: '',
|
|
15785
|
+
message: 'initial WS connection could not be established',
|
|
15786
|
+
isWSFailure: true,
|
|
15787
|
+
}));
|
|
15788
|
+
})(),
|
|
15789
|
+
]);
|
|
15790
|
+
};
|
|
15254
15791
|
/**
|
|
15255
15792
|
* Builds and returns the url for websocket.
|
|
15256
15793
|
* @private
|
|
@@ -15263,11 +15800,166 @@ class StableWSConnection {
|
|
|
15263
15800
|
params.set('X-Stream-Client', this.client.getUserAgent());
|
|
15264
15801
|
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
|
|
15265
15802
|
};
|
|
15803
|
+
/**
|
|
15804
|
+
* disconnect - Disconnect the connection and doesn't recover...
|
|
15805
|
+
*/
|
|
15806
|
+
this.disconnect = (timeout) => {
|
|
15807
|
+
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15808
|
+
this.wsID += 1;
|
|
15809
|
+
this.isConnecting = false;
|
|
15810
|
+
this.isDisconnected = true;
|
|
15811
|
+
// start by removing all the listeners
|
|
15812
|
+
if (this.healthCheckTimeoutRef) {
|
|
15813
|
+
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15814
|
+
}
|
|
15815
|
+
if (this.connectionCheckTimeoutRef) {
|
|
15816
|
+
clearInterval(this.connectionCheckTimeoutRef);
|
|
15817
|
+
}
|
|
15818
|
+
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15819
|
+
this.isHealthy = false;
|
|
15820
|
+
let isClosedPromise;
|
|
15821
|
+
// and finally close...
|
|
15822
|
+
// Assigning to local here because we will remove it from this before the
|
|
15823
|
+
// promise resolves.
|
|
15824
|
+
const { ws } = this;
|
|
15825
|
+
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15826
|
+
isClosedPromise = new Promise((resolve) => {
|
|
15827
|
+
const onclose = (event) => {
|
|
15828
|
+
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15829
|
+
resolve();
|
|
15830
|
+
};
|
|
15831
|
+
ws.onclose = onclose;
|
|
15832
|
+
// In case we don't receive close frame websocket server in time,
|
|
15833
|
+
// lets not wait for more than 1 second.
|
|
15834
|
+
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15835
|
+
});
|
|
15836
|
+
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15837
|
+
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15838
|
+
}
|
|
15839
|
+
else {
|
|
15840
|
+
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15841
|
+
isClosedPromise = Promise.resolve();
|
|
15842
|
+
}
|
|
15843
|
+
delete this.ws;
|
|
15844
|
+
return isClosedPromise;
|
|
15845
|
+
};
|
|
15846
|
+
/**
|
|
15847
|
+
* _connect - Connect to the WS endpoint
|
|
15848
|
+
*
|
|
15849
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
15850
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
15851
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
15852
|
+
* @return Promise that completes once the first health check message is received
|
|
15853
|
+
*/
|
|
15854
|
+
this._connect = async (timeoutMs) => {
|
|
15855
|
+
if (this.isConnecting)
|
|
15856
|
+
return; // ignore _connect if it's currently trying to connect
|
|
15857
|
+
this.isConnecting = true;
|
|
15858
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
15859
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
15860
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
15861
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
15862
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
15863
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
15864
|
+
// captured closure still settles only the original promise (P1) and
|
|
15865
|
+
// never poisons the newer one (P2).
|
|
15866
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15867
|
+
let isTokenReady = false;
|
|
15868
|
+
try {
|
|
15869
|
+
this._log(`_connect() - waiting for token`);
|
|
15870
|
+
await this.client.tokenManager.tokenReady();
|
|
15871
|
+
isTokenReady = true;
|
|
15872
|
+
}
|
|
15873
|
+
catch {
|
|
15874
|
+
// token provider has failed before, so try again
|
|
15875
|
+
}
|
|
15876
|
+
try {
|
|
15877
|
+
if (!isTokenReady) {
|
|
15878
|
+
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15879
|
+
await this.client.tokenManager.loadToken();
|
|
15880
|
+
}
|
|
15881
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
15882
|
+
this.client._setupConnectionIdPromise();
|
|
15883
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
15884
|
+
// closure is the one bound to the promise this attempt owns.
|
|
15885
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15886
|
+
}
|
|
15887
|
+
this._setupConnectionPromise();
|
|
15888
|
+
const wsURL = this._buildUrl();
|
|
15889
|
+
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15890
|
+
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15891
|
+
this.ws = new WS(wsURL);
|
|
15892
|
+
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15893
|
+
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15894
|
+
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15895
|
+
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15896
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
15897
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
15898
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
15899
|
+
const timers = getTimers();
|
|
15900
|
+
let handshakeTimeoutId;
|
|
15901
|
+
let response;
|
|
15902
|
+
try {
|
|
15903
|
+
response = await Promise.race([
|
|
15904
|
+
this.connectionOpen,
|
|
15905
|
+
new Promise((_, reject) => {
|
|
15906
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
15907
|
+
const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
|
|
15908
|
+
err.isWSFailure = true;
|
|
15909
|
+
reject(err);
|
|
15910
|
+
}, handshakeTimeout);
|
|
15911
|
+
}),
|
|
15912
|
+
]);
|
|
15913
|
+
}
|
|
15914
|
+
finally {
|
|
15915
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
15916
|
+
}
|
|
15917
|
+
this.isConnecting = false;
|
|
15918
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
15919
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
15920
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
15921
|
+
// a misleading "success" for a connection that has already been
|
|
15922
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
15923
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
15924
|
+
// which would observe the already-resolved connectionOpen promise
|
|
15925
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
15926
|
+
if (this.isDisconnected) {
|
|
15927
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15928
|
+
this._destroyCurrentWSConnection();
|
|
15929
|
+
}
|
|
15930
|
+
throw new Error('WS handshake aborted: disconnect() ran while connecting');
|
|
15931
|
+
}
|
|
15932
|
+
if (response) {
|
|
15933
|
+
this.connectionID = response.connection_id;
|
|
15934
|
+
this.client.resolveConnectionId?.(this.connectionID);
|
|
15935
|
+
return response;
|
|
15936
|
+
}
|
|
15937
|
+
}
|
|
15938
|
+
catch (caught) {
|
|
15939
|
+
const err = caught;
|
|
15940
|
+
this.isConnecting = false;
|
|
15941
|
+
this._log(`_connect() - Error - `, err);
|
|
15942
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
15943
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
15944
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
15945
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
15946
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
15947
|
+
// therefore fail fast instead of being orphaned.
|
|
15948
|
+
ownRejectConnectionId?.(err);
|
|
15949
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
15950
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
15951
|
+
this.rejectConnectionOpen?.(err);
|
|
15952
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
15953
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15954
|
+
this._destroyCurrentWSConnection();
|
|
15955
|
+
}
|
|
15956
|
+
throw err;
|
|
15957
|
+
}
|
|
15958
|
+
};
|
|
15266
15959
|
/**
|
|
15267
15960
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
15268
15961
|
*
|
|
15269
15962
|
* @param {Event} event Event with type online or offline
|
|
15270
|
-
*
|
|
15271
15963
|
*/
|
|
15272
15964
|
this.onlineStatusChanged = (event) => {
|
|
15273
15965
|
if (event.type === 'offline') {
|
|
@@ -15365,16 +16057,12 @@ class StableWSConnection {
|
|
|
15365
16057
|
return;
|
|
15366
16058
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
15367
16059
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
15368
|
-
// this is a permanent error raised by stream
|
|
16060
|
+
// this is a permanent error raised by stream.
|
|
15369
16061
|
// usually caused by invalid auth details
|
|
15370
16062
|
const error = new Error(`WS connection reject with error ${event.reason}`);
|
|
15371
|
-
// @ts-expect-error type issue
|
|
15372
16063
|
error.reason = event.reason;
|
|
15373
|
-
// @ts-expect-error type issue
|
|
15374
16064
|
error.code = event.code;
|
|
15375
|
-
// @ts-expect-error type issue
|
|
15376
16065
|
error.wasClean = event.wasClean;
|
|
15377
|
-
// @ts-expect-error type issue
|
|
15378
16066
|
error.target = event.target;
|
|
15379
16067
|
this.rejectConnectionOpen?.(error);
|
|
15380
16068
|
this._log(`onclose() - WS connection reject with error ${event.reason}`, {
|
|
@@ -15512,205 +16200,8 @@ class StableWSConnection {
|
|
|
15512
16200
|
}, this.connectionCheckTimeout);
|
|
15513
16201
|
};
|
|
15514
16202
|
this.client = client;
|
|
15515
|
-
/** consecutive failures influence the duration of the timeout */
|
|
15516
|
-
this.consecutiveFailures = 0;
|
|
15517
|
-
/** keep track of the total number of failures */
|
|
15518
|
-
this.totalFailures = 0;
|
|
15519
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15520
|
-
this.isConnecting = false;
|
|
15521
|
-
/** To avoid reconnect if client is disconnected */
|
|
15522
|
-
this.isDisconnected = false;
|
|
15523
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
15524
|
-
this.isConnectionOpenResolved = false;
|
|
15525
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
15526
|
-
this.isHealthy = false;
|
|
15527
|
-
/** Incremented when a new WS connection is made */
|
|
15528
|
-
this.wsID = 1;
|
|
15529
|
-
/** Store the last event time for health checks */
|
|
15530
|
-
this.lastEvent = null;
|
|
15531
|
-
/** Send a health check message every 25 seconds */
|
|
15532
|
-
this.pingInterval = 25 * 1000;
|
|
15533
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15534
16203
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
15535
16204
|
}
|
|
15536
|
-
/**
|
|
15537
|
-
* connect - Connect to the WS URL
|
|
15538
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15539
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15540
|
-
*/
|
|
15541
|
-
async connect(timeout = 15000) {
|
|
15542
|
-
if (this.isConnecting) {
|
|
15543
|
-
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15544
|
-
}
|
|
15545
|
-
this.isDisconnected = false;
|
|
15546
|
-
try {
|
|
15547
|
-
const healthCheck = await this._connect();
|
|
15548
|
-
this.consecutiveFailures = 0;
|
|
15549
|
-
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15550
|
-
}
|
|
15551
|
-
catch (error) {
|
|
15552
|
-
this.isHealthy = false;
|
|
15553
|
-
this.consecutiveFailures += 1;
|
|
15554
|
-
if (
|
|
15555
|
-
// @ts-expect-error type issue
|
|
15556
|
-
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15557
|
-
!this.client.tokenManager.isStatic()) {
|
|
15558
|
-
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15559
|
-
this._reconnect({ refreshToken: true });
|
|
15560
|
-
}
|
|
15561
|
-
else {
|
|
15562
|
-
// @ts-expect-error type issue
|
|
15563
|
-
if (!error.isWSFailure) {
|
|
15564
|
-
// API rejected the connection and we should not retry
|
|
15565
|
-
throw new Error(JSON.stringify({
|
|
15566
|
-
// @ts-expect-error type issue
|
|
15567
|
-
code: error.code,
|
|
15568
|
-
// @ts-expect-error type issue
|
|
15569
|
-
StatusCode: error.StatusCode,
|
|
15570
|
-
// @ts-expect-error type issue
|
|
15571
|
-
message: error.message,
|
|
15572
|
-
// @ts-expect-error type issue
|
|
15573
|
-
isWSFailure: error.isWSFailure,
|
|
15574
|
-
}));
|
|
15575
|
-
}
|
|
15576
|
-
}
|
|
15577
|
-
}
|
|
15578
|
-
return await this._waitForHealthy(timeout);
|
|
15579
|
-
}
|
|
15580
|
-
/**
|
|
15581
|
-
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15582
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15583
|
-
* @param timeout duration(ms)
|
|
15584
|
-
*/
|
|
15585
|
-
async _waitForHealthy(timeout = 15000) {
|
|
15586
|
-
return Promise.race([
|
|
15587
|
-
(async () => {
|
|
15588
|
-
const interval = 50; // ms
|
|
15589
|
-
for (let i = 0; i <= timeout; i += interval) {
|
|
15590
|
-
try {
|
|
15591
|
-
return await this.connectionOpen;
|
|
15592
|
-
}
|
|
15593
|
-
catch (error) {
|
|
15594
|
-
if (i === timeout) {
|
|
15595
|
-
throw new Error(JSON.stringify({
|
|
15596
|
-
code: error.code,
|
|
15597
|
-
StatusCode: error.StatusCode,
|
|
15598
|
-
message: error.message,
|
|
15599
|
-
isWSFailure: error.isWSFailure,
|
|
15600
|
-
}));
|
|
15601
|
-
}
|
|
15602
|
-
await sleep(interval);
|
|
15603
|
-
}
|
|
15604
|
-
}
|
|
15605
|
-
})(),
|
|
15606
|
-
(async () => {
|
|
15607
|
-
await sleep(timeout);
|
|
15608
|
-
this.isConnecting = false;
|
|
15609
|
-
throw new Error(JSON.stringify({
|
|
15610
|
-
code: '',
|
|
15611
|
-
StatusCode: '',
|
|
15612
|
-
message: 'initial WS connection could not be established',
|
|
15613
|
-
isWSFailure: true,
|
|
15614
|
-
}));
|
|
15615
|
-
})(),
|
|
15616
|
-
]);
|
|
15617
|
-
}
|
|
15618
|
-
/**
|
|
15619
|
-
* disconnect - Disconnect the connection and doesn't recover...
|
|
15620
|
-
*
|
|
15621
|
-
*/
|
|
15622
|
-
disconnect(timeout) {
|
|
15623
|
-
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15624
|
-
this.wsID += 1;
|
|
15625
|
-
this.isConnecting = false;
|
|
15626
|
-
this.isDisconnected = true;
|
|
15627
|
-
// start by removing all the listeners
|
|
15628
|
-
if (this.healthCheckTimeoutRef) {
|
|
15629
|
-
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15630
|
-
}
|
|
15631
|
-
if (this.connectionCheckTimeoutRef) {
|
|
15632
|
-
clearInterval(this.connectionCheckTimeoutRef);
|
|
15633
|
-
}
|
|
15634
|
-
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15635
|
-
this.isHealthy = false;
|
|
15636
|
-
let isClosedPromise;
|
|
15637
|
-
// and finally close...
|
|
15638
|
-
// Assigning to local here because we will remove it from this before the
|
|
15639
|
-
// promise resolves.
|
|
15640
|
-
const { ws } = this;
|
|
15641
|
-
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15642
|
-
isClosedPromise = new Promise((resolve) => {
|
|
15643
|
-
const onclose = (event) => {
|
|
15644
|
-
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15645
|
-
resolve();
|
|
15646
|
-
};
|
|
15647
|
-
ws.onclose = onclose;
|
|
15648
|
-
// In case we don't receive close frame websocket server in time,
|
|
15649
|
-
// lets not wait for more than 1 second.
|
|
15650
|
-
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15651
|
-
});
|
|
15652
|
-
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15653
|
-
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15654
|
-
}
|
|
15655
|
-
else {
|
|
15656
|
-
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15657
|
-
isClosedPromise = Promise.resolve();
|
|
15658
|
-
}
|
|
15659
|
-
delete this.ws;
|
|
15660
|
-
return isClosedPromise;
|
|
15661
|
-
}
|
|
15662
|
-
/**
|
|
15663
|
-
* _connect - Connect to the WS endpoint
|
|
15664
|
-
*
|
|
15665
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15666
|
-
*/
|
|
15667
|
-
async _connect() {
|
|
15668
|
-
if (this.isConnecting)
|
|
15669
|
-
return; // ignore _connect if it's currently trying to connect
|
|
15670
|
-
this.isConnecting = true;
|
|
15671
|
-
let isTokenReady = false;
|
|
15672
|
-
try {
|
|
15673
|
-
this._log(`_connect() - waiting for token`);
|
|
15674
|
-
await this.client.tokenManager.tokenReady();
|
|
15675
|
-
isTokenReady = true;
|
|
15676
|
-
}
|
|
15677
|
-
catch {
|
|
15678
|
-
// token provider has failed before, so try again
|
|
15679
|
-
}
|
|
15680
|
-
try {
|
|
15681
|
-
if (!isTokenReady) {
|
|
15682
|
-
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15683
|
-
await this.client.tokenManager.loadToken();
|
|
15684
|
-
}
|
|
15685
|
-
if (!this.client.isConnectionIsPromisePending) {
|
|
15686
|
-
this.client._setupConnectionIdPromise();
|
|
15687
|
-
}
|
|
15688
|
-
this._setupConnectionPromise();
|
|
15689
|
-
const wsURL = this._buildUrl();
|
|
15690
|
-
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15691
|
-
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15692
|
-
this.ws = new WS(wsURL);
|
|
15693
|
-
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15694
|
-
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15695
|
-
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15696
|
-
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15697
|
-
const response = await this.connectionOpen;
|
|
15698
|
-
this.isConnecting = false;
|
|
15699
|
-
if (response) {
|
|
15700
|
-
this.connectionID = response.connection_id;
|
|
15701
|
-
this.client.resolveConnectionId?.(this.connectionID);
|
|
15702
|
-
return response;
|
|
15703
|
-
}
|
|
15704
|
-
}
|
|
15705
|
-
catch (err) {
|
|
15706
|
-
this.client._setupConnectionIdPromise();
|
|
15707
|
-
this.isConnecting = false;
|
|
15708
|
-
// @ts-expect-error type issue
|
|
15709
|
-
this._log(`_connect() - Error - `, err);
|
|
15710
|
-
this.client.rejectConnectionId?.(err);
|
|
15711
|
-
throw err;
|
|
15712
|
-
}
|
|
15713
|
-
}
|
|
15714
16205
|
/**
|
|
15715
16206
|
* _reconnect - Retry the connection to WS endpoint
|
|
15716
16207
|
*
|
|
@@ -15757,7 +16248,8 @@ class StableWSConnection {
|
|
|
15757
16248
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
15758
16249
|
this.consecutiveFailures = 0;
|
|
15759
16250
|
}
|
|
15760
|
-
catch (
|
|
16251
|
+
catch (caught) {
|
|
16252
|
+
const error = caught;
|
|
15761
16253
|
this.isHealthy = false;
|
|
15762
16254
|
this.consecutiveFailures += 1;
|
|
15763
16255
|
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
@@ -16314,7 +16806,7 @@ class StreamClient {
|
|
|
16314
16806
|
this.getUserAgent = () => {
|
|
16315
16807
|
if (!this.cachedUserAgent) {
|
|
16316
16808
|
const { clientAppIdentifier = {} } = this.options;
|
|
16317
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
16809
|
+
const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
|
|
16318
16810
|
this.cachedUserAgent = [
|
|
16319
16811
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16320
16812
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16422,7 +16914,7 @@ class StreamClient {
|
|
|
16422
16914
|
get connectionIdPromise() {
|
|
16423
16915
|
return this.connectionIdPromiseSafe?.();
|
|
16424
16916
|
}
|
|
16425
|
-
get
|
|
16917
|
+
get isConnectionIdPromisePending() {
|
|
16426
16918
|
return this.connectionIdPromiseSafe?.checkPending() ?? false;
|
|
16427
16919
|
}
|
|
16428
16920
|
get wsPromise() {
|