@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.es.js
CHANGED
|
@@ -1399,6 +1399,35 @@ var ClientCapability;
|
|
|
1399
1399
|
*/
|
|
1400
1400
|
ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
|
|
1401
1401
|
})(ClientCapability || (ClientCapability = {}));
|
|
1402
|
+
/**
|
|
1403
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1404
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1405
|
+
*
|
|
1406
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1407
|
+
*/
|
|
1408
|
+
var DegradationPreference;
|
|
1409
|
+
(function (DegradationPreference) {
|
|
1410
|
+
/**
|
|
1411
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1412
|
+
*/
|
|
1413
|
+
DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
1414
|
+
/**
|
|
1415
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1416
|
+
*/
|
|
1417
|
+
DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
|
|
1418
|
+
/**
|
|
1419
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1420
|
+
*/
|
|
1421
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
|
|
1422
|
+
/**
|
|
1423
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1424
|
+
*/
|
|
1425
|
+
DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
|
|
1426
|
+
/**
|
|
1427
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1428
|
+
*/
|
|
1429
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
|
|
1430
|
+
})(DegradationPreference || (DegradationPreference = {}));
|
|
1402
1431
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1403
1432
|
class CallState$Type extends MessageType {
|
|
1404
1433
|
constructor() {
|
|
@@ -1668,6 +1697,16 @@ class PublishOption$Type extends MessageType {
|
|
|
1668
1697
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1669
1698
|
T: () => AudioBitrate,
|
|
1670
1699
|
},
|
|
1700
|
+
{
|
|
1701
|
+
no: 11,
|
|
1702
|
+
name: 'degradation_preference',
|
|
1703
|
+
kind: 'enum',
|
|
1704
|
+
T: () => [
|
|
1705
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1706
|
+
DegradationPreference,
|
|
1707
|
+
'DEGRADATION_PREFERENCE_',
|
|
1708
|
+
],
|
|
1709
|
+
},
|
|
1671
1710
|
]);
|
|
1672
1711
|
}
|
|
1673
1712
|
}
|
|
@@ -2114,6 +2153,7 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
2114
2153
|
ClientDetails: ClientDetails,
|
|
2115
2154
|
Codec: Codec,
|
|
2116
2155
|
get ConnectionQuality () { return ConnectionQuality; },
|
|
2156
|
+
get DegradationPreference () { return DegradationPreference; },
|
|
2117
2157
|
Device: Device,
|
|
2118
2158
|
Error: Error$2,
|
|
2119
2159
|
get ErrorCode () { return ErrorCode; },
|
|
@@ -3501,6 +3541,16 @@ class VideoSender$Type extends MessageType {
|
|
|
3501
3541
|
kind: 'scalar',
|
|
3502
3542
|
T: 5 /*ScalarType.INT32*/,
|
|
3503
3543
|
},
|
|
3544
|
+
{
|
|
3545
|
+
no: 6,
|
|
3546
|
+
name: 'degradation_preference',
|
|
3547
|
+
kind: 'enum',
|
|
3548
|
+
T: () => [
|
|
3549
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
3550
|
+
DegradationPreference,
|
|
3551
|
+
'DEGRADATION_PREFERENCE_',
|
|
3552
|
+
],
|
|
3553
|
+
},
|
|
3504
3554
|
]);
|
|
3505
3555
|
}
|
|
3506
3556
|
}
|
|
@@ -3866,6 +3916,18 @@ const createSignalClient = (options) => {
|
|
|
3866
3916
|
};
|
|
3867
3917
|
|
|
3868
3918
|
const sleep = (m) => new Promise((r) => setTimeout(r, m));
|
|
3919
|
+
const timeboxed = async (promises, ms) => {
|
|
3920
|
+
let timerId;
|
|
3921
|
+
const timeout = new Promise((_, reject) => {
|
|
3922
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
3923
|
+
});
|
|
3924
|
+
try {
|
|
3925
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
3926
|
+
}
|
|
3927
|
+
finally {
|
|
3928
|
+
clearTimeout(timerId);
|
|
3929
|
+
}
|
|
3930
|
+
};
|
|
3869
3931
|
function isFunction(value) {
|
|
3870
3932
|
return (value &&
|
|
3871
3933
|
(Object.prototype.toString.call(value) === '[object Function]' ||
|
|
@@ -4605,6 +4667,20 @@ const setCurrentValue = (subject, update) => {
|
|
|
4605
4667
|
subject.next(next);
|
|
4606
4668
|
return next;
|
|
4607
4669
|
};
|
|
4670
|
+
/**
|
|
4671
|
+
* Updates the value of the provided Subject asynchronously.
|
|
4672
|
+
* Locks the subject to prevent concurrent updates.
|
|
4673
|
+
*
|
|
4674
|
+
* @param subject the subject to update.
|
|
4675
|
+
* @param update the update to apply to the subject.
|
|
4676
|
+
*/
|
|
4677
|
+
const setCurrentValueAsync = async (subject, update) => {
|
|
4678
|
+
return withoutConcurrency(subject, async () => {
|
|
4679
|
+
const next = await update(getCurrentValue(subject));
|
|
4680
|
+
subject.next(next);
|
|
4681
|
+
return next;
|
|
4682
|
+
});
|
|
4683
|
+
};
|
|
4608
4684
|
/**
|
|
4609
4685
|
* Updates the value of the provided Subject and returns the previous value
|
|
4610
4686
|
* and a function to roll back the update.
|
|
@@ -4659,6 +4735,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
|
|
|
4659
4735
|
createSubscription: createSubscription,
|
|
4660
4736
|
getCurrentValue: getCurrentValue,
|
|
4661
4737
|
setCurrentValue: setCurrentValue,
|
|
4738
|
+
setCurrentValueAsync: setCurrentValueAsync,
|
|
4662
4739
|
updateValue: updateValue
|
|
4663
4740
|
});
|
|
4664
4741
|
|
|
@@ -6283,7 +6360,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6283
6360
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6284
6361
|
};
|
|
6285
6362
|
|
|
6286
|
-
const version = "1.
|
|
6363
|
+
const version = "1.50.0";
|
|
6287
6364
|
const [major, minor, patch] = version.split('.');
|
|
6288
6365
|
let sdkInfo = {
|
|
6289
6366
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -6436,6 +6513,31 @@ const isSafari = () => {
|
|
|
6436
6513
|
return false;
|
|
6437
6514
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
6438
6515
|
};
|
|
6516
|
+
/**
|
|
6517
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
6518
|
+
*
|
|
6519
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
6520
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
6521
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
6522
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
6523
|
+
* share the underlying WebKit quirks.
|
|
6524
|
+
*
|
|
6525
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
6526
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
6527
|
+
*/
|
|
6528
|
+
const isWebKit = () => {
|
|
6529
|
+
if (typeof navigator === 'undefined')
|
|
6530
|
+
return false;
|
|
6531
|
+
const ua = navigator.userAgent || '';
|
|
6532
|
+
if (!/AppleWebKit\//.test(ua))
|
|
6533
|
+
return false;
|
|
6534
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
6535
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
6536
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
6537
|
+
// the mobile Blink stack.
|
|
6538
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
6539
|
+
return !regExp.test(ua);
|
|
6540
|
+
};
|
|
6439
6541
|
/**
|
|
6440
6542
|
* Checks whether the current browser is Firefox.
|
|
6441
6543
|
*/
|
|
@@ -6479,7 +6581,8 @@ var browsers = /*#__PURE__*/Object.freeze({
|
|
|
6479
6581
|
isChrome: isChrome,
|
|
6480
6582
|
isFirefox: isFirefox,
|
|
6481
6583
|
isSafari: isSafari,
|
|
6482
|
-
isSupportedBrowser: isSupportedBrowser
|
|
6584
|
+
isSupportedBrowser: isSupportedBrowser,
|
|
6585
|
+
isWebKit: isWebKit
|
|
6483
6586
|
});
|
|
6484
6587
|
|
|
6485
6588
|
/**
|
|
@@ -7955,6 +8058,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
|
|
|
7955
8058
|
}));
|
|
7956
8059
|
};
|
|
7957
8060
|
|
|
8061
|
+
const toRTCDegradationPreference = (preference) => {
|
|
8062
|
+
switch (preference) {
|
|
8063
|
+
case DegradationPreference.BALANCED:
|
|
8064
|
+
return 'balanced';
|
|
8065
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
8066
|
+
return 'maintain-framerate';
|
|
8067
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
8068
|
+
return 'maintain-resolution';
|
|
8069
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
8070
|
+
// @ts-expect-error not in the typedefs yet
|
|
8071
|
+
return 'maintain-framerate-and-resolution';
|
|
8072
|
+
case DegradationPreference.UNSPECIFIED:
|
|
8073
|
+
return undefined;
|
|
8074
|
+
default:
|
|
8075
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8076
|
+
}
|
|
8077
|
+
};
|
|
8078
|
+
|
|
7958
8079
|
/**
|
|
7959
8080
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
7960
8081
|
*
|
|
@@ -8016,7 +8137,9 @@ class Publisher extends BasePeerConnection {
|
|
|
8016
8137
|
sendEncodings,
|
|
8017
8138
|
});
|
|
8018
8139
|
const params = transceiver.sender.getParameters();
|
|
8019
|
-
params.degradationPreference =
|
|
8140
|
+
params.degradationPreference =
|
|
8141
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
8142
|
+
'maintain-framerate';
|
|
8020
8143
|
await transceiver.sender.setParameters(params);
|
|
8021
8144
|
const trackType = publishOption.trackType;
|
|
8022
8145
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
@@ -8113,6 +8236,40 @@ class Publisher extends BasePeerConnection {
|
|
|
8113
8236
|
}
|
|
8114
8237
|
return false;
|
|
8115
8238
|
};
|
|
8239
|
+
/**
|
|
8240
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
8241
|
+
* reattaching the currently published track on each matching sender.
|
|
8242
|
+
*
|
|
8243
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
8244
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
8245
|
+
* can stop producing RTP packets even though the underlying
|
|
8246
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
8247
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
8248
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
8249
|
+
* flow with the same SSRC.
|
|
8250
|
+
*
|
|
8251
|
+
* No-op when nothing is published for the given track type.
|
|
8252
|
+
*
|
|
8253
|
+
* @param trackType the track type to refresh.
|
|
8254
|
+
*/
|
|
8255
|
+
this.refreshTrack = async (trackType) => {
|
|
8256
|
+
for (const item of this.transceiverCache.items()) {
|
|
8257
|
+
if (item.publishOption.trackType !== trackType)
|
|
8258
|
+
continue;
|
|
8259
|
+
const { sender } = item.transceiver;
|
|
8260
|
+
const track = sender.track;
|
|
8261
|
+
if (!track || track.readyState !== 'live')
|
|
8262
|
+
continue;
|
|
8263
|
+
try {
|
|
8264
|
+
await sender.replaceTrack(null);
|
|
8265
|
+
await sender.replaceTrack(track);
|
|
8266
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
8267
|
+
}
|
|
8268
|
+
catch (err) {
|
|
8269
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
8270
|
+
}
|
|
8271
|
+
}
|
|
8272
|
+
};
|
|
8116
8273
|
/**
|
|
8117
8274
|
* Stops the cloned track that is being published to the SFU.
|
|
8118
8275
|
*/
|
|
@@ -8190,6 +8347,12 @@ class Publisher extends BasePeerConnection {
|
|
|
8190
8347
|
changed = true;
|
|
8191
8348
|
}
|
|
8192
8349
|
}
|
|
8350
|
+
const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
|
|
8351
|
+
if (degradationPreference &&
|
|
8352
|
+
params.degradationPreference !== degradationPreference) {
|
|
8353
|
+
params.degradationPreference = degradationPreference;
|
|
8354
|
+
changed = true;
|
|
8355
|
+
}
|
|
8193
8356
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
8194
8357
|
if (!changed) {
|
|
8195
8358
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -8370,6 +8533,36 @@ class Publisher extends BasePeerConnection {
|
|
|
8370
8533
|
}
|
|
8371
8534
|
}
|
|
8372
8535
|
|
|
8536
|
+
/**
|
|
8537
|
+
* Adds unique values to an array.
|
|
8538
|
+
*
|
|
8539
|
+
* @param arr the array to add to.
|
|
8540
|
+
* @param values the values to add.
|
|
8541
|
+
*/
|
|
8542
|
+
const pushToIfMissing = (arr, ...values) => {
|
|
8543
|
+
for (const v of values) {
|
|
8544
|
+
if (!arr.includes(v)) {
|
|
8545
|
+
arr.push(v);
|
|
8546
|
+
}
|
|
8547
|
+
}
|
|
8548
|
+
return arr;
|
|
8549
|
+
};
|
|
8550
|
+
/**
|
|
8551
|
+
* Removes values from an array if they are present.
|
|
8552
|
+
*
|
|
8553
|
+
* @param arr the array to remove from.
|
|
8554
|
+
* @param values the values to remove.
|
|
8555
|
+
*/
|
|
8556
|
+
const removeFromIfPresent = (arr, ...values) => {
|
|
8557
|
+
for (const v of values) {
|
|
8558
|
+
const index = arr.indexOf(v);
|
|
8559
|
+
if (index !== -1) {
|
|
8560
|
+
arr.splice(index, 1);
|
|
8561
|
+
}
|
|
8562
|
+
}
|
|
8563
|
+
return arr;
|
|
8564
|
+
};
|
|
8565
|
+
|
|
8373
8566
|
/**
|
|
8374
8567
|
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
8375
8568
|
* media streams from the SFU.
|
|
@@ -8411,27 +8604,34 @@ class Subscriber extends BasePeerConnection {
|
|
|
8411
8604
|
}
|
|
8412
8605
|
};
|
|
8413
8606
|
this.handleOnTrack = (e) => {
|
|
8414
|
-
const
|
|
8607
|
+
const { streams, track } = e;
|
|
8608
|
+
const [primaryStream] = streams;
|
|
8415
8609
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
8416
8610
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
8417
8611
|
const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8418
|
-
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
8612
|
+
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
|
|
8613
|
+
const trackType = toTrackType(rawTrackType);
|
|
8614
|
+
if (!trackType) {
|
|
8615
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8616
|
+
}
|
|
8419
8617
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
8420
|
-
|
|
8618
|
+
track.addEventListener('mute', () => {
|
|
8421
8619
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
8620
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8422
8621
|
});
|
|
8423
|
-
|
|
8622
|
+
track.addEventListener('unmute', () => {
|
|
8424
8623
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
8624
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8425
8625
|
});
|
|
8426
|
-
|
|
8626
|
+
track.addEventListener('ended', () => {
|
|
8427
8627
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
8628
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8428
8629
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
8429
8630
|
});
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8631
|
+
if (track.muted) {
|
|
8632
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8433
8633
|
}
|
|
8434
|
-
this.trackIdToTrackType.set(
|
|
8634
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
8435
8635
|
if (!participantToUpdate) {
|
|
8436
8636
|
this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
|
|
8437
8637
|
this.state.registerOrphanedTrack({
|
|
@@ -8457,13 +8657,30 @@ class Subscriber extends BasePeerConnection {
|
|
|
8457
8657
|
});
|
|
8458
8658
|
// now, dispose the previous stream if it exists
|
|
8459
8659
|
if (previousStream) {
|
|
8460
|
-
this.logger.info(`[onTrack]: Cleaning up previous remote ${
|
|
8660
|
+
this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
8461
8661
|
previousStream.getTracks().forEach((t) => {
|
|
8462
8662
|
t.stop();
|
|
8463
8663
|
previousStream.removeTrack(t);
|
|
8464
8664
|
});
|
|
8465
8665
|
}
|
|
8466
8666
|
};
|
|
8667
|
+
this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
|
|
8668
|
+
if (trackType !== TrackType.AUDIO)
|
|
8669
|
+
return;
|
|
8670
|
+
const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8671
|
+
if (!target)
|
|
8672
|
+
return;
|
|
8673
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
8674
|
+
const current = p.interruptedTracks ?? [];
|
|
8675
|
+
const has = current.includes(trackType);
|
|
8676
|
+
if (interrupted === has)
|
|
8677
|
+
return {};
|
|
8678
|
+
const next = interrupted
|
|
8679
|
+
? pushToIfMissing([...current], trackType)
|
|
8680
|
+
: removeFromIfPresent([...current], trackType);
|
|
8681
|
+
return { interruptedTracks: next };
|
|
8682
|
+
});
|
|
8683
|
+
};
|
|
8467
8684
|
this.negotiate = async (subscriberOffer) => {
|
|
8468
8685
|
await this.pc.setRemoteDescription({
|
|
8469
8686
|
type: 'offer',
|
|
@@ -9186,36 +9403,6 @@ const watchCallGrantsUpdated = (state) => {
|
|
|
9186
9403
|
};
|
|
9187
9404
|
};
|
|
9188
9405
|
|
|
9189
|
-
/**
|
|
9190
|
-
* Adds unique values to an array.
|
|
9191
|
-
*
|
|
9192
|
-
* @param arr the array to add to.
|
|
9193
|
-
* @param values the values to add.
|
|
9194
|
-
*/
|
|
9195
|
-
const pushToIfMissing = (arr, ...values) => {
|
|
9196
|
-
for (const v of values) {
|
|
9197
|
-
if (!arr.includes(v)) {
|
|
9198
|
-
arr.push(v);
|
|
9199
|
-
}
|
|
9200
|
-
}
|
|
9201
|
-
return arr;
|
|
9202
|
-
};
|
|
9203
|
-
/**
|
|
9204
|
-
* Removes values from an array if they are present.
|
|
9205
|
-
*
|
|
9206
|
-
* @param arr the array to remove from.
|
|
9207
|
-
* @param values the values to remove.
|
|
9208
|
-
*/
|
|
9209
|
-
const removeFromIfPresent = (arr, ...values) => {
|
|
9210
|
-
for (const v of values) {
|
|
9211
|
-
const index = arr.indexOf(v);
|
|
9212
|
-
if (index !== -1) {
|
|
9213
|
-
arr.splice(index, 1);
|
|
9214
|
-
}
|
|
9215
|
-
}
|
|
9216
|
-
return arr;
|
|
9217
|
-
};
|
|
9218
|
-
|
|
9219
9406
|
const watchConnectionQualityChanged = (dispatcher, state) => {
|
|
9220
9407
|
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
9221
9408
|
const { connectionQualityUpdates } = e;
|
|
@@ -9548,91 +9735,6 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
9548
9735
|
};
|
|
9549
9736
|
};
|
|
9550
9737
|
|
|
9551
|
-
const DEFAULT_THRESHOLD = 0.35;
|
|
9552
|
-
class ViewportTracker {
|
|
9553
|
-
constructor() {
|
|
9554
|
-
/**
|
|
9555
|
-
* @private
|
|
9556
|
-
*/
|
|
9557
|
-
this.elementHandlerMap = new Map();
|
|
9558
|
-
/**
|
|
9559
|
-
* @private
|
|
9560
|
-
*/
|
|
9561
|
-
this.observer = null;
|
|
9562
|
-
// in React children render before viewport is set, add
|
|
9563
|
-
// them to the queue and observe them once the observer is ready
|
|
9564
|
-
/**
|
|
9565
|
-
* @private
|
|
9566
|
-
*/
|
|
9567
|
-
this.queueSet = new Set();
|
|
9568
|
-
/**
|
|
9569
|
-
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
9570
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9571
|
-
*
|
|
9572
|
-
* @param viewportElement
|
|
9573
|
-
* @param options
|
|
9574
|
-
* @returns Unobserve
|
|
9575
|
-
*/
|
|
9576
|
-
this.setViewport = (viewportElement, options) => {
|
|
9577
|
-
const cleanup = () => {
|
|
9578
|
-
this.observer?.disconnect();
|
|
9579
|
-
this.observer = null;
|
|
9580
|
-
this.elementHandlerMap.clear();
|
|
9581
|
-
};
|
|
9582
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
9583
|
-
entries.forEach((entry) => {
|
|
9584
|
-
const handler = this.elementHandlerMap.get(entry.target);
|
|
9585
|
-
handler?.(entry);
|
|
9586
|
-
});
|
|
9587
|
-
}, {
|
|
9588
|
-
root: viewportElement,
|
|
9589
|
-
...options,
|
|
9590
|
-
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
9591
|
-
});
|
|
9592
|
-
if (this.queueSet.size) {
|
|
9593
|
-
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
9594
|
-
// check if element which requested observation is
|
|
9595
|
-
// a child of a viewport element, skip if isn't
|
|
9596
|
-
if (!viewportElement.contains(queueElement))
|
|
9597
|
-
return;
|
|
9598
|
-
this.observer.observe(queueElement);
|
|
9599
|
-
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
9600
|
-
});
|
|
9601
|
-
this.queueSet.clear();
|
|
9602
|
-
}
|
|
9603
|
-
return cleanup;
|
|
9604
|
-
};
|
|
9605
|
-
/**
|
|
9606
|
-
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
9607
|
-
* detects a possible change in element's visibility within specified viewport, returns
|
|
9608
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9609
|
-
*
|
|
9610
|
-
* @param element
|
|
9611
|
-
* @param handler
|
|
9612
|
-
* @returns Unobserve
|
|
9613
|
-
*/
|
|
9614
|
-
this.observe = (element, handler) => {
|
|
9615
|
-
const queueItem = [element, handler];
|
|
9616
|
-
const cleanup = () => {
|
|
9617
|
-
this.elementHandlerMap.delete(element);
|
|
9618
|
-
this.observer?.unobserve(element);
|
|
9619
|
-
this.queueSet.delete(queueItem);
|
|
9620
|
-
};
|
|
9621
|
-
if (this.elementHandlerMap.has(element))
|
|
9622
|
-
return cleanup;
|
|
9623
|
-
if (!this.observer) {
|
|
9624
|
-
this.queueSet.add(queueItem);
|
|
9625
|
-
return cleanup;
|
|
9626
|
-
}
|
|
9627
|
-
if (this.observer.root.contains(element)) {
|
|
9628
|
-
this.elementHandlerMap.set(element, handler);
|
|
9629
|
-
this.observer.observe(element);
|
|
9630
|
-
}
|
|
9631
|
-
return cleanup;
|
|
9632
|
-
};
|
|
9633
|
-
}
|
|
9634
|
-
}
|
|
9635
|
-
|
|
9636
9738
|
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9637
9739
|
/**
|
|
9638
9740
|
* Tracks audio element bindings and periodically warns about
|
|
@@ -9640,8 +9742,6 @@ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${tr
|
|
|
9640
9742
|
*/
|
|
9641
9743
|
class AudioBindingsWatchdog {
|
|
9642
9744
|
constructor(state, tracer) {
|
|
9643
|
-
this.state = state;
|
|
9644
|
-
this.tracer = tracer;
|
|
9645
9745
|
this.bindings = new Map();
|
|
9646
9746
|
this.enabled = true;
|
|
9647
9747
|
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
@@ -9649,14 +9749,14 @@ class AudioBindingsWatchdog {
|
|
|
9649
9749
|
* Registers an audio element binding for the given session and track type.
|
|
9650
9750
|
* Warns if a different element is already bound to the same key.
|
|
9651
9751
|
*/
|
|
9652
|
-
this.register = (
|
|
9752
|
+
this.register = (element, sessionId, trackType) => {
|
|
9653
9753
|
const key = toBindingKey(sessionId, trackType);
|
|
9654
9754
|
const existing = this.bindings.get(key);
|
|
9655
|
-
if (existing && existing !==
|
|
9755
|
+
if (existing && existing !== element) {
|
|
9656
9756
|
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9657
9757
|
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9658
9758
|
}
|
|
9659
|
-
this.bindings.set(key,
|
|
9759
|
+
this.bindings.set(key, element);
|
|
9660
9760
|
};
|
|
9661
9761
|
/**
|
|
9662
9762
|
* Removes the audio element binding for the given session and track type.
|
|
@@ -9682,6 +9782,7 @@ class AudioBindingsWatchdog {
|
|
|
9682
9782
|
*/
|
|
9683
9783
|
this.dispose = () => {
|
|
9684
9784
|
this.stop();
|
|
9785
|
+
this.bindings.clear();
|
|
9685
9786
|
this.unsubscribeCallingState();
|
|
9686
9787
|
};
|
|
9687
9788
|
this.start = () => {
|
|
@@ -9713,6 +9814,8 @@ class AudioBindingsWatchdog {
|
|
|
9713
9814
|
this.stop = () => {
|
|
9714
9815
|
clearInterval(this.watchdogInterval);
|
|
9715
9816
|
};
|
|
9817
|
+
this.tracer = tracer;
|
|
9818
|
+
this.state = state;
|
|
9716
9819
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
9717
9820
|
if (!this.enabled)
|
|
9718
9821
|
return;
|
|
@@ -9726,64 +9829,100 @@ class AudioBindingsWatchdog {
|
|
|
9726
9829
|
}
|
|
9727
9830
|
}
|
|
9728
9831
|
|
|
9729
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
9730
|
-
videoTrack: VisibilityState.UNKNOWN,
|
|
9731
|
-
screenShareTrack: VisibilityState.UNKNOWN,
|
|
9732
|
-
};
|
|
9733
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9734
9832
|
/**
|
|
9735
|
-
*
|
|
9736
|
-
*
|
|
9737
|
-
* - binding video elements to session ids
|
|
9738
|
-
* - binding audio elements to session ids
|
|
9739
|
-
* - tracking element visibility
|
|
9740
|
-
* - updating subscriptions based on viewport visibility
|
|
9741
|
-
* - updating subscriptions based on video element dimensions
|
|
9742
|
-
* - updating subscriptions based on published tracks
|
|
9833
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9743
9834
|
*/
|
|
9744
|
-
class
|
|
9745
|
-
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
constructor(callState, speaker, tracer) {
|
|
9835
|
+
class BlockedAudioTracker {
|
|
9836
|
+
constructor(tracer) {
|
|
9837
|
+
this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
9838
|
+
this.blockedElementsSubject = new BehaviorSubject(new Set());
|
|
9749
9839
|
/**
|
|
9750
|
-
*
|
|
9840
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
9841
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
9842
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
9751
9843
|
*/
|
|
9752
|
-
this.
|
|
9753
|
-
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
9754
|
-
this.useWebAudio = false;
|
|
9755
|
-
this.pendingSubscriptionsUpdate = null;
|
|
9844
|
+
this.autoplayBlocked$ = this.blockedElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
|
|
9756
9845
|
/**
|
|
9757
|
-
*
|
|
9758
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
9846
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
9759
9847
|
*/
|
|
9760
|
-
this.
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
this.addBlockedAudioElement = (audioElement) => {
|
|
9768
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
9769
|
-
const next = new Set(elements);
|
|
9770
|
-
next.add(audioElement);
|
|
9771
|
-
return next;
|
|
9848
|
+
this.markBlocked = (audioElement, blocked) => {
|
|
9849
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
9850
|
+
if (blocked)
|
|
9851
|
+
elements.add(audioElement);
|
|
9852
|
+
else
|
|
9853
|
+
elements.delete(audioElement);
|
|
9854
|
+
return elements;
|
|
9772
9855
|
});
|
|
9773
9856
|
};
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
|
|
9778
|
-
|
|
9857
|
+
/**
|
|
9858
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
9859
|
+
* by the browser's autoplay policy.
|
|
9860
|
+
*/
|
|
9861
|
+
this.isBlocked = (audioElement) => {
|
|
9862
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
9863
|
+
};
|
|
9864
|
+
/**
|
|
9865
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
9866
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
9867
|
+
*/
|
|
9868
|
+
this.resumeAudio = async () => {
|
|
9869
|
+
this.tracer.trace('resumeAudio', null);
|
|
9870
|
+
await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
|
|
9871
|
+
await Promise.all(Array.from(elements, async (element) => {
|
|
9872
|
+
try {
|
|
9873
|
+
if (element.srcObject)
|
|
9874
|
+
await timeboxed([element.play()], 2000);
|
|
9875
|
+
elements.delete(element);
|
|
9876
|
+
}
|
|
9877
|
+
catch (err) {
|
|
9878
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
9879
|
+
}
|
|
9880
|
+
}));
|
|
9881
|
+
return elements;
|
|
9779
9882
|
});
|
|
9780
9883
|
};
|
|
9781
|
-
this.
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
|
|
9884
|
+
this.tracer = tracer;
|
|
9885
|
+
}
|
|
9886
|
+
}
|
|
9887
|
+
|
|
9888
|
+
/** Symbol key for the "applies to all participants" override slot. */
|
|
9889
|
+
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9890
|
+
/**
|
|
9891
|
+
* Owns the SFU-side video-subscription machinery for a `Call`:
|
|
9892
|
+
*
|
|
9893
|
+
* - Holds the per-session / global override state in a
|
|
9894
|
+
* `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
|
|
9895
|
+
* - Derives the SFU subscription list from `CallState` participants +
|
|
9896
|
+
* current overrides via the `subscriptions` getter.
|
|
9897
|
+
* - Debounces and pushes the list to the SFU through
|
|
9898
|
+
* `sfuClient.updateSubscriptions`.
|
|
9899
|
+
* - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
|
|
9900
|
+
* the override state for React hooks.
|
|
9901
|
+
*
|
|
9902
|
+
* Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
|
|
9903
|
+
* `DynascaleManager.bindVideoElement` triggers `apply()` on every
|
|
9904
|
+
* dimension / visibility change.
|
|
9905
|
+
*/
|
|
9906
|
+
class TrackSubscriptionManager {
|
|
9907
|
+
/**
|
|
9908
|
+
* Constructs new TrackSubscriptionManager instance.
|
|
9909
|
+
*
|
|
9910
|
+
* @param callState the call state.
|
|
9911
|
+
* @param tracer the tracer to use.
|
|
9912
|
+
*/
|
|
9913
|
+
constructor(callState, tracer) {
|
|
9914
|
+
this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
|
|
9915
|
+
this.pendingUpdate = null;
|
|
9916
|
+
this.overridesSubject = new BehaviorSubject({});
|
|
9917
|
+
this.overrides$ = this.overridesSubject.asObservable();
|
|
9918
|
+
/**
|
|
9919
|
+
* Consumer-friendly projection of the override state. Used by the
|
|
9920
|
+
* `useIncomingVideoSettings()` React hook.
|
|
9921
|
+
*/
|
|
9922
|
+
this.incomingVideoSettings$ = this.overrides$.pipe(map((overrides) => {
|
|
9923
|
+
const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
|
|
9924
|
+
return {
|
|
9925
|
+
enabled: globalSettings?.enabled !== false,
|
|
9787
9926
|
preferredResolution: globalSettings?.enabled
|
|
9788
9927
|
? globalSettings.dimension
|
|
9789
9928
|
: undefined,
|
|
@@ -9802,106 +9941,255 @@ class DynascaleManager {
|
|
|
9802
9941
|
};
|
|
9803
9942
|
}), shareReplay(1));
|
|
9804
9943
|
/**
|
|
9805
|
-
*
|
|
9944
|
+
* Sets the SFU client used by `apply()` to push subscription updates.
|
|
9945
|
+
* Called by the owner on call join; cleared on leave.
|
|
9806
9946
|
*/
|
|
9807
|
-
this.
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
|
|
9813
|
-
|
|
9814
|
-
if (
|
|
9815
|
-
|
|
9816
|
-
|
|
9817
|
-
this.audioContext = undefined;
|
|
9947
|
+
this.setSfuClient = (sfuClient) => {
|
|
9948
|
+
this.sfuClient = sfuClient;
|
|
9949
|
+
};
|
|
9950
|
+
/**
|
|
9951
|
+
* Cancels any pending debounced subscription push. Idempotent.
|
|
9952
|
+
*/
|
|
9953
|
+
this.dispose = () => {
|
|
9954
|
+
if (this.pendingUpdate) {
|
|
9955
|
+
clearTimeout(this.pendingUpdate);
|
|
9956
|
+
this.pendingUpdate = null;
|
|
9818
9957
|
}
|
|
9819
9958
|
};
|
|
9820
|
-
|
|
9821
|
-
|
|
9822
|
-
|
|
9823
|
-
|
|
9824
|
-
|
|
9959
|
+
/**
|
|
9960
|
+
* Sets video-subscription overrides. Called by
|
|
9961
|
+
* `Call.setIncomingVideoEnabled` and
|
|
9962
|
+
* `Call.setPreferredIncomingVideoResolution`.
|
|
9963
|
+
*
|
|
9964
|
+
* - `sessionIds` omitted → applies `override` globally (or clears the
|
|
9965
|
+
* global override if `override` is `undefined`).
|
|
9966
|
+
* - `sessionIds` provided → applies `override` to each listed session.
|
|
9967
|
+
*/
|
|
9968
|
+
this.setOverrides = (override, sessionIds) => {
|
|
9969
|
+
this.tracer.trace('setOverrides', [override, sessionIds]);
|
|
9825
9970
|
if (!sessionIds) {
|
|
9826
|
-
return setCurrentValue(this.
|
|
9971
|
+
return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
|
|
9827
9972
|
}
|
|
9828
|
-
return setCurrentValue(this.
|
|
9973
|
+
return setCurrentValue(this.overridesSubject, (overrides) => ({
|
|
9829
9974
|
...overrides,
|
|
9830
9975
|
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
9831
9976
|
}));
|
|
9832
9977
|
};
|
|
9833
|
-
|
|
9834
|
-
|
|
9835
|
-
|
|
9978
|
+
/**
|
|
9979
|
+
* Pushes `subscriptions` to the SFU. Debounced by `debounceType`
|
|
9980
|
+
* (SLOW by default). Multiple rapid calls coalesce into one RPC.
|
|
9981
|
+
* Passing `0` fires synchronously.
|
|
9982
|
+
*/
|
|
9983
|
+
this.apply = (debounceType = DebounceType.SLOW) => {
|
|
9984
|
+
if (this.pendingUpdate) {
|
|
9985
|
+
clearTimeout(this.pendingUpdate);
|
|
9836
9986
|
}
|
|
9837
9987
|
const updateSubscriptions = () => {
|
|
9838
|
-
this.
|
|
9988
|
+
this.pendingUpdate = null;
|
|
9839
9989
|
this.sfuClient
|
|
9840
|
-
?.updateSubscriptions(this.
|
|
9990
|
+
?.updateSubscriptions(this.subscriptions)
|
|
9841
9991
|
.catch((err) => {
|
|
9842
9992
|
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
9843
9993
|
});
|
|
9844
9994
|
};
|
|
9845
9995
|
if (debounceType) {
|
|
9846
|
-
this.
|
|
9996
|
+
this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
|
|
9847
9997
|
}
|
|
9848
9998
|
else {
|
|
9849
9999
|
updateSubscriptions();
|
|
9850
10000
|
}
|
|
9851
10001
|
};
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
9855
|
-
|
|
9856
|
-
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
10002
|
+
this.tracer = tracer;
|
|
10003
|
+
this.callState = callState;
|
|
10004
|
+
}
|
|
10005
|
+
/**
|
|
10006
|
+
* The current SFU subscription list, computed from `CallState`
|
|
10007
|
+
* participants and the override state. Used by:
|
|
10008
|
+
*
|
|
10009
|
+
* - `apply()` to push to the SFU each time the set changes.
|
|
10010
|
+
* - `Call.getReconnectDetails` to include the subscription list in
|
|
10011
|
+
* the reconnect payload.
|
|
10012
|
+
*/
|
|
10013
|
+
get subscriptions() {
|
|
10014
|
+
const subscriptions = [];
|
|
10015
|
+
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
10016
|
+
// and avoid stale data caused by shareReplay with no active subscribers
|
|
10017
|
+
const participants = this.callState.getParticipantsSnapshot();
|
|
10018
|
+
const overrides = this.overridesSubject.getValue();
|
|
10019
|
+
for (const p of participants) {
|
|
10020
|
+
if (p.isLocalParticipant)
|
|
10021
|
+
continue;
|
|
10022
|
+
// NOTE: audio tracks don't have to be requested explicitly
|
|
10023
|
+
// as the SFU will implicitly subscribe us to all of them,
|
|
10024
|
+
// once they become available.
|
|
10025
|
+
if (p.videoDimension && hasVideo(p)) {
|
|
10026
|
+
const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
|
|
10027
|
+
if (override?.enabled !== false) {
|
|
10028
|
+
subscriptions.push({
|
|
10029
|
+
userId: p.userId,
|
|
10030
|
+
sessionId: p.sessionId,
|
|
10031
|
+
trackType: TrackType.VIDEO,
|
|
10032
|
+
dimension: override?.dimension ?? p.videoDimension,
|
|
10033
|
+
});
|
|
10034
|
+
}
|
|
10035
|
+
}
|
|
10036
|
+
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
10037
|
+
subscriptions.push({
|
|
10038
|
+
userId: p.userId,
|
|
10039
|
+
sessionId: p.sessionId,
|
|
10040
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10041
|
+
dimension: p.screenShareDimension,
|
|
9878
10042
|
});
|
|
10043
|
+
}
|
|
10044
|
+
if (hasScreenShareAudio(p)) {
|
|
10045
|
+
subscriptions.push({
|
|
10046
|
+
userId: p.userId,
|
|
10047
|
+
sessionId: p.sessionId,
|
|
10048
|
+
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
10049
|
+
});
|
|
10050
|
+
}
|
|
10051
|
+
}
|
|
10052
|
+
return subscriptions;
|
|
10053
|
+
}
|
|
10054
|
+
get overrides() {
|
|
10055
|
+
return getCurrentValue(this.overrides$);
|
|
10056
|
+
}
|
|
10057
|
+
}
|
|
10058
|
+
|
|
10059
|
+
/**
|
|
10060
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
10061
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
10062
|
+
*/
|
|
10063
|
+
class MediaPlaybackWatchdog {
|
|
10064
|
+
constructor(opts) {
|
|
10065
|
+
this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
10066
|
+
this.controller = new AbortController();
|
|
10067
|
+
this.attempt = 0;
|
|
10068
|
+
this.disposed = false;
|
|
10069
|
+
this.attach = () => {
|
|
10070
|
+
if (this.disposed)
|
|
10071
|
+
return;
|
|
10072
|
+
const { signal } = this.controller;
|
|
10073
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
10074
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
10075
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
10076
|
+
};
|
|
10077
|
+
this.dispose = () => {
|
|
10078
|
+
if (this.disposed)
|
|
10079
|
+
return;
|
|
10080
|
+
this.disposed = true;
|
|
10081
|
+
this.controller.abort();
|
|
10082
|
+
if (this.pendingTimer)
|
|
10083
|
+
clearTimeout(this.pendingTimer);
|
|
10084
|
+
this.pendingTimer = undefined;
|
|
10085
|
+
};
|
|
10086
|
+
this.onPlaying = () => {
|
|
10087
|
+
if (this.attempt > 0) {
|
|
10088
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
10089
|
+
kind: this.kind,
|
|
10090
|
+
attempts: this.attempt,
|
|
10091
|
+
});
|
|
10092
|
+
}
|
|
10093
|
+
this.attempt = 0;
|
|
10094
|
+
if (this.pendingTimer)
|
|
10095
|
+
clearTimeout(this.pendingTimer);
|
|
10096
|
+
this.pendingTimer = undefined;
|
|
10097
|
+
};
|
|
10098
|
+
this.onPauseOrSuspend = (event) => {
|
|
10099
|
+
if (this.disposed)
|
|
10100
|
+
return;
|
|
10101
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
10102
|
+
kind: this.kind,
|
|
10103
|
+
reason: event.type,
|
|
9879
10104
|
});
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
viewportVisibilityState: {
|
|
9891
|
-
...previousVisibilityState,
|
|
9892
|
-
[trackType]: VisibilityState.UNKNOWN,
|
|
9893
|
-
},
|
|
9894
|
-
};
|
|
10105
|
+
this.scheduleRecovery();
|
|
10106
|
+
};
|
|
10107
|
+
this.scheduleRecovery = () => {
|
|
10108
|
+
if (this.disposed || this.pendingTimer)
|
|
10109
|
+
return;
|
|
10110
|
+
const skipReason = this.computeSkipReason();
|
|
10111
|
+
if (skipReason) {
|
|
10112
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
10113
|
+
kind: this.kind,
|
|
10114
|
+
reason: skipReason,
|
|
9895
10115
|
});
|
|
9896
|
-
|
|
10116
|
+
return;
|
|
10117
|
+
}
|
|
10118
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
10119
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
10120
|
+
};
|
|
10121
|
+
this.computeSkipReason = () => {
|
|
10122
|
+
if (this.disposed)
|
|
10123
|
+
return 'disposed';
|
|
10124
|
+
if (!this.element.srcObject)
|
|
10125
|
+
return 'noSrc';
|
|
10126
|
+
if (this.element.ended)
|
|
10127
|
+
return 'ended';
|
|
10128
|
+
if (this.isBlocked())
|
|
10129
|
+
return 'blocked';
|
|
10130
|
+
const HAVE_CURRENT_DATA = 2;
|
|
10131
|
+
if (this.element.readyState < HAVE_CURRENT_DATA)
|
|
10132
|
+
return 'notReady';
|
|
10133
|
+
if (!this.element.paused)
|
|
10134
|
+
return 'notPaused';
|
|
10135
|
+
};
|
|
10136
|
+
this.attemptPlay = async () => {
|
|
10137
|
+
this.pendingTimer = undefined;
|
|
10138
|
+
if (this.disposed)
|
|
10139
|
+
return;
|
|
10140
|
+
this.attempt += 1;
|
|
10141
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
10142
|
+
kind: this.kind,
|
|
10143
|
+
attempt: this.attempt,
|
|
10144
|
+
});
|
|
10145
|
+
try {
|
|
10146
|
+
await timeboxed([this.element.play()], 2000);
|
|
10147
|
+
}
|
|
10148
|
+
catch (err) {
|
|
10149
|
+
if (this.disposed)
|
|
10150
|
+
return;
|
|
10151
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
10152
|
+
if (this.attempt >= 10) {
|
|
10153
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
10154
|
+
kind: this.kind,
|
|
10155
|
+
attempts: this.attempt,
|
|
10156
|
+
});
|
|
10157
|
+
return;
|
|
10158
|
+
}
|
|
10159
|
+
this.scheduleRecovery();
|
|
10160
|
+
}
|
|
9897
10161
|
};
|
|
10162
|
+
this.element = opts.element;
|
|
10163
|
+
this.kind = opts.kind;
|
|
10164
|
+
this.tracer = opts.tracer;
|
|
10165
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
10166
|
+
this.attach();
|
|
10167
|
+
}
|
|
10168
|
+
}
|
|
10169
|
+
|
|
10170
|
+
/**
|
|
10171
|
+
* A manager class that handles dynascale related tasks like:
|
|
10172
|
+
*
|
|
10173
|
+
* - binding video elements to session ids
|
|
10174
|
+
* - binding audio elements to session ids
|
|
10175
|
+
*/
|
|
10176
|
+
class DynascaleManager {
|
|
10177
|
+
/**
|
|
10178
|
+
* Creates a new DynascaleManager instance.
|
|
10179
|
+
*/
|
|
10180
|
+
constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
|
|
10181
|
+
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
10182
|
+
this.useWebAudio = false;
|
|
9898
10183
|
/**
|
|
9899
|
-
*
|
|
9900
|
-
*
|
|
9901
|
-
* @param element the viewport element.
|
|
10184
|
+
* Closes the audio context if it was created.
|
|
9902
10185
|
*/
|
|
9903
|
-
this.
|
|
9904
|
-
|
|
10186
|
+
this.dispose = async () => {
|
|
10187
|
+
const context = this.audioContext;
|
|
10188
|
+
if (context && context.state !== 'closed') {
|
|
10189
|
+
document.removeEventListener('click', this.resumeAudioContext);
|
|
10190
|
+
await context.close();
|
|
10191
|
+
this.audioContext = undefined;
|
|
10192
|
+
}
|
|
9905
10193
|
};
|
|
9906
10194
|
/**
|
|
9907
10195
|
* Sets whether to use WebAudio API for audio playback.
|
|
@@ -9946,7 +10234,7 @@ class DynascaleManager {
|
|
|
9946
10234
|
this.callState.updateParticipantTracks(trackType, {
|
|
9947
10235
|
[sessionId]: { dimension },
|
|
9948
10236
|
});
|
|
9949
|
-
this.
|
|
10237
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
9950
10238
|
};
|
|
9951
10239
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
9952
10240
|
/**
|
|
@@ -10035,6 +10323,11 @@ class DynascaleManager {
|
|
|
10035
10323
|
// without prior user interaction:
|
|
10036
10324
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
10037
10325
|
videoElement.muted = true;
|
|
10326
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
10327
|
+
element: videoElement,
|
|
10328
|
+
kind: 'video',
|
|
10329
|
+
tracer: this.tracer,
|
|
10330
|
+
});
|
|
10038
10331
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
10039
10332
|
const streamSubscription = participant$
|
|
10040
10333
|
.pipe(distinctUntilKeyChanged(trackKey))
|
|
@@ -10044,14 +10337,14 @@ class DynascaleManager {
|
|
|
10044
10337
|
return;
|
|
10045
10338
|
videoElement.srcObject = source ?? null;
|
|
10046
10339
|
if (isSafari() || isFirefox()) {
|
|
10047
|
-
setTimeout(() => {
|
|
10340
|
+
setTimeout(async () => {
|
|
10048
10341
|
videoElement.srcObject = source ?? null;
|
|
10049
|
-
|
|
10342
|
+
try {
|
|
10343
|
+
await timeboxed([videoElement.play()], 2000);
|
|
10344
|
+
}
|
|
10345
|
+
catch (e) {
|
|
10050
10346
|
this.logger.warn(`Failed to play stream`, e);
|
|
10051
|
-
}
|
|
10052
|
-
// we add extra delay until we attempt to force-play
|
|
10053
|
-
// the participant's media stream in Firefox and Safari,
|
|
10054
|
-
// as they seem to have some timing issues
|
|
10347
|
+
}
|
|
10055
10348
|
}, 25);
|
|
10056
10349
|
}
|
|
10057
10350
|
});
|
|
@@ -10061,6 +10354,7 @@ class DynascaleManager {
|
|
|
10061
10354
|
publishedTracksSubscription?.unsubscribe();
|
|
10062
10355
|
streamSubscription.unsubscribe();
|
|
10063
10356
|
resizeObserver?.disconnect();
|
|
10357
|
+
playbackWatchdog.dispose();
|
|
10064
10358
|
};
|
|
10065
10359
|
};
|
|
10066
10360
|
/**
|
|
@@ -10078,7 +10372,6 @@ class DynascaleManager {
|
|
|
10078
10372
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
10079
10373
|
if (!participant || participant.isLocalParticipant)
|
|
10080
10374
|
return;
|
|
10081
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
10082
10375
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
10083
10376
|
const updateSinkId = (deviceId, audioContext) => {
|
|
10084
10377
|
if (!deviceId)
|
|
@@ -10097,6 +10390,7 @@ class DynascaleManager {
|
|
|
10097
10390
|
};
|
|
10098
10391
|
let sourceNode = undefined;
|
|
10099
10392
|
let gainNode = undefined;
|
|
10393
|
+
let audioWatchdog = undefined;
|
|
10100
10394
|
const isAudioTrack = trackType === 'audioTrack';
|
|
10101
10395
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
10102
10396
|
const updateMediaStreamSubscription = participant$
|
|
@@ -10107,8 +10401,10 @@ class DynascaleManager {
|
|
|
10107
10401
|
return;
|
|
10108
10402
|
setTimeout(() => {
|
|
10109
10403
|
audioElement.srcObject = source ?? null;
|
|
10404
|
+
audioWatchdog?.dispose();
|
|
10405
|
+
audioWatchdog = undefined;
|
|
10110
10406
|
if (!source) {
|
|
10111
|
-
this.
|
|
10407
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10112
10408
|
return;
|
|
10113
10409
|
}
|
|
10114
10410
|
// Safari has a special quirk that prevents playing audio until the user
|
|
@@ -10136,10 +10432,16 @@ class DynascaleManager {
|
|
|
10136
10432
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
10137
10433
|
if (e.name === 'NotAllowedError') {
|
|
10138
10434
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
10139
|
-
this.
|
|
10435
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
10140
10436
|
}
|
|
10141
10437
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
10142
10438
|
});
|
|
10439
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
10440
|
+
element: audioElement,
|
|
10441
|
+
kind: 'audio',
|
|
10442
|
+
tracer: this.tracer,
|
|
10443
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
10444
|
+
});
|
|
10143
10445
|
}
|
|
10144
10446
|
const { selectedDevice } = this.speaker.state;
|
|
10145
10447
|
if (selectedDevice)
|
|
@@ -10163,38 +10465,17 @@ class DynascaleManager {
|
|
|
10163
10465
|
});
|
|
10164
10466
|
audioElement.autoplay = true;
|
|
10165
10467
|
return () => {
|
|
10166
|
-
this.
|
|
10167
|
-
this.removeBlockedAudioElement(audioElement);
|
|
10468
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10168
10469
|
sinkIdSubscription?.unsubscribe();
|
|
10169
10470
|
volumeSubscription.unsubscribe();
|
|
10170
10471
|
updateMediaStreamSubscription.unsubscribe();
|
|
10171
10472
|
audioElement.srcObject = null;
|
|
10172
10473
|
sourceNode?.disconnect();
|
|
10173
10474
|
gainNode?.disconnect();
|
|
10475
|
+
audioWatchdog?.dispose();
|
|
10476
|
+
audioWatchdog = undefined;
|
|
10174
10477
|
};
|
|
10175
10478
|
};
|
|
10176
|
-
/**
|
|
10177
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10178
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
10179
|
-
*
|
|
10180
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
10181
|
-
*/
|
|
10182
|
-
this.resumeAudio = async () => {
|
|
10183
|
-
this.tracer.trace('resumeAudio', null);
|
|
10184
|
-
const blocked = new Set();
|
|
10185
|
-
await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
|
|
10186
|
-
try {
|
|
10187
|
-
if (el.srcObject) {
|
|
10188
|
-
await el.play();
|
|
10189
|
-
}
|
|
10190
|
-
}
|
|
10191
|
-
catch {
|
|
10192
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
10193
|
-
blocked.add(el);
|
|
10194
|
-
}
|
|
10195
|
-
}));
|
|
10196
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
10197
|
-
};
|
|
10198
10479
|
this.getOrCreateAudioContext = () => {
|
|
10199
10480
|
if (!this.useWebAudio)
|
|
10200
10481
|
return;
|
|
@@ -10247,57 +10528,124 @@ class DynascaleManager {
|
|
|
10247
10528
|
this.callState = callState;
|
|
10248
10529
|
this.speaker = speaker;
|
|
10249
10530
|
this.tracer = tracer;
|
|
10250
|
-
|
|
10251
|
-
|
|
10252
|
-
}
|
|
10253
|
-
}
|
|
10254
|
-
setSfuClient(sfuClient) {
|
|
10255
|
-
this.sfuClient = sfuClient;
|
|
10531
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
10532
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
10256
10533
|
}
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
|
|
10271
|
-
|
|
10272
|
-
|
|
10273
|
-
|
|
10274
|
-
|
|
10275
|
-
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10534
|
+
}
|
|
10535
|
+
|
|
10536
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
10537
|
+
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
10538
|
+
videoTrack: VisibilityState.UNKNOWN,
|
|
10539
|
+
screenShareTrack: VisibilityState.UNKNOWN,
|
|
10540
|
+
};
|
|
10541
|
+
class ViewportTracker {
|
|
10542
|
+
constructor(callState) {
|
|
10543
|
+
this.elementHandlerMap = new Map();
|
|
10544
|
+
this.observer = null;
|
|
10545
|
+
// in React children render before viewport is set, add
|
|
10546
|
+
// them to the queue and observe them once the observer is ready
|
|
10547
|
+
this.queueSet = new Set();
|
|
10548
|
+
/**
|
|
10549
|
+
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
10550
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10551
|
+
*/
|
|
10552
|
+
this.setViewport = (viewportElement, options) => {
|
|
10553
|
+
const cleanup = () => {
|
|
10554
|
+
this.observer?.disconnect();
|
|
10555
|
+
this.observer = null;
|
|
10556
|
+
this.elementHandlerMap.clear();
|
|
10557
|
+
};
|
|
10558
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
10559
|
+
entries.forEach((entry) => {
|
|
10560
|
+
const handler = this.elementHandlerMap.get(entry.target);
|
|
10561
|
+
handler?.(entry);
|
|
10562
|
+
});
|
|
10563
|
+
}, {
|
|
10564
|
+
root: viewportElement,
|
|
10565
|
+
...options,
|
|
10566
|
+
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
10567
|
+
});
|
|
10568
|
+
if (this.queueSet.size) {
|
|
10569
|
+
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
10570
|
+
// check if element which requested observation is
|
|
10571
|
+
// a child of a viewport element, skip if isn't
|
|
10572
|
+
if (!viewportElement.contains(queueElement))
|
|
10573
|
+
return;
|
|
10574
|
+
this.observer.observe(queueElement);
|
|
10575
|
+
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
10576
|
+
});
|
|
10577
|
+
this.queueSet.clear();
|
|
10280
10578
|
}
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10579
|
+
return cleanup;
|
|
10580
|
+
};
|
|
10581
|
+
/**
|
|
10582
|
+
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
10583
|
+
* detects a possible change in element's visibility within specified viewport, returns
|
|
10584
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10585
|
+
*/
|
|
10586
|
+
this.observe = (element, handler) => {
|
|
10587
|
+
const queueItem = [element, handler];
|
|
10588
|
+
const cleanup = () => {
|
|
10589
|
+
this.elementHandlerMap.delete(element);
|
|
10590
|
+
this.observer?.unobserve(element);
|
|
10591
|
+
this.queueSet.delete(queueItem);
|
|
10592
|
+
};
|
|
10593
|
+
if (this.elementHandlerMap.has(element))
|
|
10594
|
+
return cleanup;
|
|
10595
|
+
if (!this.observer) {
|
|
10596
|
+
this.queueSet.add(queueItem);
|
|
10597
|
+
return cleanup;
|
|
10598
|
+
}
|
|
10599
|
+
if (this.observer.root.contains(element)) {
|
|
10600
|
+
this.elementHandlerMap.set(element, handler);
|
|
10601
|
+
this.observer.observe(element);
|
|
10602
|
+
}
|
|
10603
|
+
return cleanup;
|
|
10604
|
+
};
|
|
10605
|
+
/**
|
|
10606
|
+
* Tracks the given element for visibility changes and mirrors the result
|
|
10607
|
+
* into `participant.viewportVisibilityState[trackType]` in `CallState`.
|
|
10608
|
+
* Returns a function that unobserves the element and resets the visibility
|
|
10609
|
+
* state back to `UNKNOWN`.
|
|
10610
|
+
*/
|
|
10611
|
+
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
10612
|
+
const cleanup = this.observe(element, (entry) => {
|
|
10613
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10614
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10615
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10616
|
+
// observer triggers when the element is "moved" to be a fullscreen element
|
|
10617
|
+
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
10618
|
+
const isVisible = entry.isIntersecting || document.fullscreenElement === element
|
|
10619
|
+
? VisibilityState.VISIBLE
|
|
10620
|
+
: VisibilityState.INVISIBLE;
|
|
10621
|
+
return {
|
|
10622
|
+
...participant,
|
|
10623
|
+
viewportVisibilityState: {
|
|
10624
|
+
...previousVisibilityState,
|
|
10625
|
+
[trackType]: isVisible,
|
|
10626
|
+
},
|
|
10627
|
+
};
|
|
10287
10628
|
});
|
|
10288
|
-
}
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10629
|
+
});
|
|
10630
|
+
return () => {
|
|
10631
|
+
cleanup();
|
|
10632
|
+
// reset visibility state to UNKNOWN upon cleanup
|
|
10633
|
+
// so that the layouts that are not actively observed
|
|
10634
|
+
// can still function normally (runtime layout switching)
|
|
10635
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10636
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10637
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10638
|
+
return {
|
|
10639
|
+
...participant,
|
|
10640
|
+
viewportVisibilityState: {
|
|
10641
|
+
...previousVisibilityState,
|
|
10642
|
+
[trackType]: VisibilityState.UNKNOWN,
|
|
10643
|
+
},
|
|
10644
|
+
};
|
|
10294
10645
|
});
|
|
10295
|
-
}
|
|
10296
|
-
}
|
|
10297
|
-
|
|
10298
|
-
}
|
|
10299
|
-
get videoTrackSubscriptionOverrides() {
|
|
10300
|
-
return getCurrentValue(this.videoTrackSubscriptionOverrides$);
|
|
10646
|
+
};
|
|
10647
|
+
};
|
|
10648
|
+
this.callState = callState;
|
|
10301
10649
|
}
|
|
10302
10650
|
}
|
|
10303
10651
|
|
|
@@ -11015,6 +11363,7 @@ class DeviceManager {
|
|
|
11015
11363
|
*/
|
|
11016
11364
|
this.stopOnLeave = true;
|
|
11017
11365
|
this.subscriptions = [];
|
|
11366
|
+
this.currentStreamCleanups = [];
|
|
11018
11367
|
this.areSubscriptionsSetUp = false;
|
|
11019
11368
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
11020
11369
|
this.filters = [];
|
|
@@ -11026,10 +11375,30 @@ class DeviceManager {
|
|
|
11026
11375
|
* @internal
|
|
11027
11376
|
*/
|
|
11028
11377
|
this.dispose = () => {
|
|
11378
|
+
this.runCurrentStreamCleanups();
|
|
11029
11379
|
this.subscriptions.forEach((s) => s());
|
|
11030
11380
|
this.subscriptions = [];
|
|
11031
11381
|
this.areSubscriptionsSetUp = false;
|
|
11032
11382
|
};
|
|
11383
|
+
this.runCurrentStreamCleanups = () => {
|
|
11384
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
11385
|
+
this.currentStreamCleanups = [];
|
|
11386
|
+
};
|
|
11387
|
+
this.setLocalInterrupted = (interrupted) => {
|
|
11388
|
+
const localParticipant = this.call.state.localParticipant;
|
|
11389
|
+
if (!localParticipant)
|
|
11390
|
+
return;
|
|
11391
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
11392
|
+
const current = p.interruptedTracks ?? [];
|
|
11393
|
+
const has = current.includes(this.trackType);
|
|
11394
|
+
if (interrupted === has)
|
|
11395
|
+
return {};
|
|
11396
|
+
const next = interrupted
|
|
11397
|
+
? pushToIfMissing([...current], this.trackType)
|
|
11398
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
11399
|
+
return { interruptedTracks: next };
|
|
11400
|
+
});
|
|
11401
|
+
};
|
|
11033
11402
|
this.call = call;
|
|
11034
11403
|
this.state = state;
|
|
11035
11404
|
this.trackType = trackType;
|
|
@@ -11253,7 +11622,9 @@ class DeviceManager {
|
|
|
11253
11622
|
// @ts-expect-error called to dispose the stream in RN
|
|
11254
11623
|
mediaStream.release();
|
|
11255
11624
|
}
|
|
11625
|
+
this.runCurrentStreamCleanups();
|
|
11256
11626
|
this.state.setMediaStream(undefined, undefined);
|
|
11627
|
+
this.setLocalInterrupted(false);
|
|
11257
11628
|
this.filters.forEach((entry) => entry.stop?.());
|
|
11258
11629
|
}
|
|
11259
11630
|
}
|
|
@@ -11289,13 +11660,17 @@ class DeviceManager {
|
|
|
11289
11660
|
async unmuteStream() {
|
|
11290
11661
|
this.logger.debug('Starting stream');
|
|
11291
11662
|
let stream;
|
|
11292
|
-
let
|
|
11663
|
+
let rootStreamPromise;
|
|
11293
11664
|
if (this.state.mediaStream &&
|
|
11294
11665
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
11295
11666
|
stream = this.state.mediaStream;
|
|
11296
11667
|
this.enableTracks();
|
|
11297
11668
|
}
|
|
11298
11669
|
else {
|
|
11670
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
11671
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
11672
|
+
// before chainWith below registers new ones for the new chain.
|
|
11673
|
+
this.runCurrentStreamCleanups();
|
|
11299
11674
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11300
11675
|
const constraints = {
|
|
11301
11676
|
...defaultConstraints,
|
|
@@ -11351,7 +11726,7 @@ class DeviceManager {
|
|
|
11351
11726
|
});
|
|
11352
11727
|
};
|
|
11353
11728
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
11354
|
-
this.
|
|
11729
|
+
this.currentStreamCleanups.push(() => {
|
|
11355
11730
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
11356
11731
|
});
|
|
11357
11732
|
});
|
|
@@ -11359,7 +11734,7 @@ class DeviceManager {
|
|
|
11359
11734
|
};
|
|
11360
11735
|
// the rootStream represents the stream coming from the actual device
|
|
11361
11736
|
// e.g. camera or microphone stream
|
|
11362
|
-
|
|
11737
|
+
rootStreamPromise = this.getStream(constraints);
|
|
11363
11738
|
// we publish the last MediaStream of the chain
|
|
11364
11739
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11365
11740
|
.then((inputStream) => {
|
|
@@ -11370,42 +11745,70 @@ class DeviceManager {
|
|
|
11370
11745
|
.then(chainWith(parent), (error) => {
|
|
11371
11746
|
this.logger.warn('Filter failed to start and will be ignored', error);
|
|
11372
11747
|
return parent;
|
|
11373
|
-
}),
|
|
11748
|
+
}), rootStreamPromise);
|
|
11374
11749
|
}
|
|
11375
11750
|
if (this.call.state.callingState === CallingState.JOINED) {
|
|
11376
11751
|
await this.publishStream(stream);
|
|
11377
11752
|
}
|
|
11378
11753
|
if (this.state.mediaStream !== stream) {
|
|
11379
|
-
|
|
11380
|
-
|
|
11381
|
-
|
|
11382
|
-
|
|
11383
|
-
this.
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
|
|
11389
|
-
|
|
11390
|
-
|
|
11391
|
-
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
this.
|
|
11395
|
-
|
|
11396
|
-
|
|
11397
|
-
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11754
|
+
const rootStream = await rootStreamPromise;
|
|
11755
|
+
this.state.setMediaStream(stream, rootStream);
|
|
11756
|
+
if (rootStream) {
|
|
11757
|
+
const handleTrackEnded = async () => {
|
|
11758
|
+
this.setLocalInterrupted(false);
|
|
11759
|
+
await this.statusChangeSettled();
|
|
11760
|
+
if (this.enabled) {
|
|
11761
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
11762
|
+
setTimeout(() => {
|
|
11763
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
11764
|
+
}, 2000);
|
|
11765
|
+
await this.disable();
|
|
11766
|
+
}
|
|
11767
|
+
};
|
|
11768
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
11769
|
+
this.setLocalInterrupted(muted);
|
|
11770
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
11771
|
+
// macOS audio session interruption even though the track is
|
|
11772
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
11773
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
11774
|
+
// page is hidden because the encoder won't resume until
|
|
11775
|
+
// foreground anyway.
|
|
11776
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
11777
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
11778
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
11779
|
+
});
|
|
11780
|
+
}
|
|
11781
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
11782
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
11783
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
11784
|
+
trackType: TrackType[this.trackType],
|
|
11785
|
+
muted,
|
|
11786
|
+
});
|
|
11787
|
+
this.call
|
|
11788
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
11789
|
+
.catch((err) => {
|
|
11790
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
11791
|
+
});
|
|
11792
|
+
}
|
|
11793
|
+
};
|
|
11794
|
+
rootStream.getTracks().forEach((track) => {
|
|
11795
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
11796
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
11797
|
+
track.addEventListener('mute', muteHandler);
|
|
11798
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
11799
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
11800
|
+
this.currentStreamCleanups.push(() => {
|
|
11801
|
+
track.removeEventListener('mute', muteHandler);
|
|
11802
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
11803
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
11804
|
+
});
|
|
11407
11805
|
});
|
|
11408
|
-
|
|
11806
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
11807
|
+
this.setLocalInterrupted(initialMuted);
|
|
11808
|
+
}
|
|
11809
|
+
else {
|
|
11810
|
+
this.setLocalInterrupted(false);
|
|
11811
|
+
}
|
|
11409
11812
|
}
|
|
11410
11813
|
}
|
|
11411
11814
|
get mediaDeviceKind() {
|
|
@@ -11551,7 +11954,6 @@ class DeviceManagerState {
|
|
|
11551
11954
|
this.defaultConstraintsSubject = new BehaviorSubject(undefined);
|
|
11552
11955
|
/**
|
|
11553
11956
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
11554
|
-
*
|
|
11555
11957
|
*/
|
|
11556
11958
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11557
11959
|
/**
|
|
@@ -13206,8 +13608,10 @@ class Call {
|
|
|
13206
13608
|
this.publisher = undefined;
|
|
13207
13609
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13208
13610
|
this.sfuClient = undefined;
|
|
13209
|
-
this.
|
|
13210
|
-
|
|
13611
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
13612
|
+
this.trackSubscriptionManager.dispose();
|
|
13613
|
+
this.audioBindingsWatchdog?.dispose();
|
|
13614
|
+
await this.dynascaleManager?.dispose();
|
|
13211
13615
|
this.state.setCallingState(CallingState.LEFT);
|
|
13212
13616
|
this.state.setParticipants([]);
|
|
13213
13617
|
this.state.dispose();
|
|
@@ -13517,7 +13921,7 @@ class Call {
|
|
|
13517
13921
|
: previousSfuClient;
|
|
13518
13922
|
this.sfuClient = sfuClient;
|
|
13519
13923
|
this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
|
|
13520
|
-
this.
|
|
13924
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
13521
13925
|
const clientDetails = await getClientDetails();
|
|
13522
13926
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
13523
13927
|
if (previousSfuClient !== sfuClient) {
|
|
@@ -13652,7 +14056,7 @@ class Call {
|
|
|
13652
14056
|
return {
|
|
13653
14057
|
strategy,
|
|
13654
14058
|
announcedTracks,
|
|
13655
|
-
subscriptions: this.
|
|
14059
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
13656
14060
|
reconnectAttempt: this.reconnectAttempts,
|
|
13657
14061
|
fromSfuId: migratingFromSfuId || '',
|
|
13658
14062
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -14199,7 +14603,7 @@ class Call {
|
|
|
14199
14603
|
const { remoteParticipants } = this.state;
|
|
14200
14604
|
if (remoteParticipants.length <= 0)
|
|
14201
14605
|
return;
|
|
14202
|
-
this.
|
|
14606
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
14203
14607
|
};
|
|
14204
14608
|
/**
|
|
14205
14609
|
* Starts publishing the given video stream to the call.
|
|
@@ -14299,6 +14703,20 @@ class Call {
|
|
|
14299
14703
|
}));
|
|
14300
14704
|
}
|
|
14301
14705
|
};
|
|
14706
|
+
/**
|
|
14707
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
14708
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
14709
|
+
* interruption (Siri, PSTN call).
|
|
14710
|
+
*
|
|
14711
|
+
* @internal
|
|
14712
|
+
*
|
|
14713
|
+
* @param trackType the track type to refresh.
|
|
14714
|
+
*/
|
|
14715
|
+
this.refreshPublishedTrack = async (trackType) => {
|
|
14716
|
+
if (!this.publisher)
|
|
14717
|
+
return;
|
|
14718
|
+
await this.publisher.refreshTrack(trackType);
|
|
14719
|
+
};
|
|
14302
14720
|
/**
|
|
14303
14721
|
* Updates the preferred publishing options
|
|
14304
14722
|
*
|
|
@@ -14960,7 +15378,7 @@ class Call {
|
|
|
14960
15378
|
* @param trackType the video mode.
|
|
14961
15379
|
*/
|
|
14962
15380
|
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
14963
|
-
return this.
|
|
15381
|
+
return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
|
|
14964
15382
|
};
|
|
14965
15383
|
/**
|
|
14966
15384
|
* Sets the viewport element to track bound video elements for visibility.
|
|
@@ -14968,7 +15386,7 @@ class Call {
|
|
|
14968
15386
|
* @param element the viewport element.
|
|
14969
15387
|
*/
|
|
14970
15388
|
this.setViewport = (element) => {
|
|
14971
|
-
return this.
|
|
15389
|
+
return this.viewportTracker?.setViewport(element);
|
|
14972
15390
|
};
|
|
14973
15391
|
/**
|
|
14974
15392
|
* Binds a DOM <video> element to the given session id.
|
|
@@ -14986,7 +15404,7 @@ class Call {
|
|
|
14986
15404
|
* @param trackType the kind of video.
|
|
14987
15405
|
*/
|
|
14988
15406
|
this.bindVideoElement = (videoElement, sessionId, trackType) => {
|
|
14989
|
-
const unbind = this.dynascaleManager
|
|
15407
|
+
const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
|
|
14990
15408
|
if (!unbind)
|
|
14991
15409
|
return;
|
|
14992
15410
|
this.leaveCallHooks.add(unbind);
|
|
@@ -15006,21 +15424,28 @@ class Call {
|
|
|
15006
15424
|
* @param trackType the kind of audio.
|
|
15007
15425
|
*/
|
|
15008
15426
|
this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
|
|
15009
|
-
const unbind = this.dynascaleManager
|
|
15427
|
+
const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
|
|
15010
15428
|
if (!unbind)
|
|
15011
15429
|
return;
|
|
15012
|
-
this.
|
|
15013
|
-
|
|
15014
|
-
this.leaveCallHooks.delete(unbind);
|
|
15430
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
15431
|
+
const cleanup = () => {
|
|
15015
15432
|
unbind();
|
|
15433
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
15434
|
+
};
|
|
15435
|
+
this.leaveCallHooks.add(cleanup);
|
|
15436
|
+
return () => {
|
|
15437
|
+
this.leaveCallHooks.delete(cleanup);
|
|
15438
|
+
cleanup();
|
|
15016
15439
|
};
|
|
15017
15440
|
};
|
|
15018
15441
|
/**
|
|
15019
15442
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
15443
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
15444
|
+
*
|
|
15445
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
15446
|
+
* gesture is required.
|
|
15020
15447
|
*/
|
|
15021
|
-
this.resumeAudio = () =>
|
|
15022
|
-
return this.dynascaleManager.resumeAudio();
|
|
15023
|
-
};
|
|
15448
|
+
this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
15024
15449
|
/**
|
|
15025
15450
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
15026
15451
|
*
|
|
@@ -15058,21 +15483,21 @@ class Call {
|
|
|
15058
15483
|
* preference has effect on. Affects all participants by default.
|
|
15059
15484
|
*/
|
|
15060
15485
|
this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
|
|
15061
|
-
this.
|
|
15486
|
+
this.trackSubscriptionManager.setOverrides(resolution
|
|
15062
15487
|
? {
|
|
15063
15488
|
enabled: true,
|
|
15064
15489
|
dimension: resolution,
|
|
15065
15490
|
}
|
|
15066
15491
|
: undefined, sessionIds);
|
|
15067
|
-
this.
|
|
15492
|
+
this.trackSubscriptionManager.apply();
|
|
15068
15493
|
};
|
|
15069
15494
|
/**
|
|
15070
15495
|
* Enables or disables incoming video from all remote call participants,
|
|
15071
15496
|
* and removes any preference for preferred resolution.
|
|
15072
15497
|
*/
|
|
15073
15498
|
this.setIncomingVideoEnabled = (enabled) => {
|
|
15074
|
-
this.
|
|
15075
|
-
this.
|
|
15499
|
+
this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
|
|
15500
|
+
this.trackSubscriptionManager.apply();
|
|
15076
15501
|
};
|
|
15077
15502
|
/**
|
|
15078
15503
|
* Sets the maximum amount of time a user can remain waiting for a reconnect
|
|
@@ -15153,7 +15578,13 @@ class Call {
|
|
|
15153
15578
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
15154
15579
|
this.speaker = new SpeakerManager(this, preferences);
|
|
15155
15580
|
this.screenShare = new ScreenShareManager(this);
|
|
15156
|
-
this.
|
|
15581
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
|
|
15582
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
15583
|
+
if (typeof document !== 'undefined') {
|
|
15584
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
|
|
15585
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
15586
|
+
this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
|
|
15587
|
+
}
|
|
15157
15588
|
}
|
|
15158
15589
|
/**
|
|
15159
15590
|
* A flag indicating whether the call is "ringing" type of call.
|
|
@@ -15226,12 +15657,118 @@ const APIErrorCodes = {
|
|
|
15226
15657
|
*/
|
|
15227
15658
|
class StableWSConnection {
|
|
15228
15659
|
constructor(client) {
|
|
15660
|
+
/** Incremented when a new WS connection is made */
|
|
15661
|
+
this.wsID = 1;
|
|
15662
|
+
// Connection lifecycle flags.
|
|
15663
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15664
|
+
this.isConnecting = false;
|
|
15665
|
+
/** To avoid reconnect if client is disconnected */
|
|
15666
|
+
this.isDisconnected = false;
|
|
15667
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
15668
|
+
this.isHealthy = false;
|
|
15669
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
15670
|
+
this.isConnectionOpenResolved = false;
|
|
15671
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
15672
|
+
/** consecutive failures influence the duration of the timeout */
|
|
15673
|
+
this.consecutiveFailures = 0;
|
|
15674
|
+
/** keep track of the total number of failures */
|
|
15675
|
+
this.totalFailures = 0;
|
|
15676
|
+
// Health-check pings + connection-staleness check.
|
|
15677
|
+
/** Send a health check message every 25 seconds */
|
|
15678
|
+
this.pingInterval = 25 * 1000;
|
|
15679
|
+
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15680
|
+
/** Store the last event time for health checks */
|
|
15681
|
+
this.lastEvent = null;
|
|
15229
15682
|
this._log = (msg, extra = {}, level = 'info') => {
|
|
15230
15683
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
15231
15684
|
};
|
|
15232
15685
|
this.setClient = (client) => {
|
|
15233
15686
|
this.client = client;
|
|
15234
15687
|
};
|
|
15688
|
+
/**
|
|
15689
|
+
* connect - Connect to the WS URL
|
|
15690
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15691
|
+
* @return Promise that completes once the first health check message is received
|
|
15692
|
+
*/
|
|
15693
|
+
this.connect = async (timeout = 15000) => {
|
|
15694
|
+
if (this.isConnecting) {
|
|
15695
|
+
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15696
|
+
}
|
|
15697
|
+
this.isDisconnected = false;
|
|
15698
|
+
try {
|
|
15699
|
+
const healthCheck = await this._connect(timeout);
|
|
15700
|
+
this.consecutiveFailures = 0;
|
|
15701
|
+
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15702
|
+
}
|
|
15703
|
+
catch (caught) {
|
|
15704
|
+
const error = caught;
|
|
15705
|
+
this.isHealthy = false;
|
|
15706
|
+
this.consecutiveFailures += 1;
|
|
15707
|
+
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15708
|
+
!this.client.tokenManager.isStatic()) {
|
|
15709
|
+
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15710
|
+
this._reconnect({ refreshToken: true });
|
|
15711
|
+
}
|
|
15712
|
+
else if (!error.isWSFailure) {
|
|
15713
|
+
// API rejected the connection and we should not retry
|
|
15714
|
+
throw new Error(JSON.stringify({
|
|
15715
|
+
code: error.code,
|
|
15716
|
+
StatusCode: error.StatusCode,
|
|
15717
|
+
message: error.message,
|
|
15718
|
+
isWSFailure: error.isWSFailure,
|
|
15719
|
+
}));
|
|
15720
|
+
}
|
|
15721
|
+
else {
|
|
15722
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
15723
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
15724
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
15725
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
15726
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
15727
|
+
// own catch when _connect was called from there.
|
|
15728
|
+
this._reconnect();
|
|
15729
|
+
}
|
|
15730
|
+
}
|
|
15731
|
+
return await this._waitForHealthy(timeout);
|
|
15732
|
+
};
|
|
15733
|
+
/**
|
|
15734
|
+
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15735
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15736
|
+
* @param timeout duration(ms)
|
|
15737
|
+
*/
|
|
15738
|
+
this._waitForHealthy = async (timeout = 15000) => {
|
|
15739
|
+
return Promise.race([
|
|
15740
|
+
(async () => {
|
|
15741
|
+
const interval = 50; // ms
|
|
15742
|
+
for (let i = 0; i <= timeout; i += interval) {
|
|
15743
|
+
try {
|
|
15744
|
+
return await this.connectionOpen;
|
|
15745
|
+
}
|
|
15746
|
+
catch (caught) {
|
|
15747
|
+
const error = caught;
|
|
15748
|
+
if (i === timeout) {
|
|
15749
|
+
throw new Error(JSON.stringify({
|
|
15750
|
+
code: error.code,
|
|
15751
|
+
StatusCode: error.StatusCode,
|
|
15752
|
+
message: error.message,
|
|
15753
|
+
isWSFailure: error.isWSFailure,
|
|
15754
|
+
}));
|
|
15755
|
+
}
|
|
15756
|
+
await sleep(interval);
|
|
15757
|
+
}
|
|
15758
|
+
}
|
|
15759
|
+
})(),
|
|
15760
|
+
(async () => {
|
|
15761
|
+
await sleep(timeout);
|
|
15762
|
+
this.isConnecting = false;
|
|
15763
|
+
throw new Error(JSON.stringify({
|
|
15764
|
+
code: '',
|
|
15765
|
+
StatusCode: '',
|
|
15766
|
+
message: 'initial WS connection could not be established',
|
|
15767
|
+
isWSFailure: true,
|
|
15768
|
+
}));
|
|
15769
|
+
})(),
|
|
15770
|
+
]);
|
|
15771
|
+
};
|
|
15235
15772
|
/**
|
|
15236
15773
|
* Builds and returns the url for websocket.
|
|
15237
15774
|
* @private
|
|
@@ -15244,11 +15781,166 @@ class StableWSConnection {
|
|
|
15244
15781
|
params.set('X-Stream-Client', this.client.getUserAgent());
|
|
15245
15782
|
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
|
|
15246
15783
|
};
|
|
15784
|
+
/**
|
|
15785
|
+
* disconnect - Disconnect the connection and doesn't recover...
|
|
15786
|
+
*/
|
|
15787
|
+
this.disconnect = (timeout) => {
|
|
15788
|
+
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15789
|
+
this.wsID += 1;
|
|
15790
|
+
this.isConnecting = false;
|
|
15791
|
+
this.isDisconnected = true;
|
|
15792
|
+
// start by removing all the listeners
|
|
15793
|
+
if (this.healthCheckTimeoutRef) {
|
|
15794
|
+
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15795
|
+
}
|
|
15796
|
+
if (this.connectionCheckTimeoutRef) {
|
|
15797
|
+
clearInterval(this.connectionCheckTimeoutRef);
|
|
15798
|
+
}
|
|
15799
|
+
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15800
|
+
this.isHealthy = false;
|
|
15801
|
+
let isClosedPromise;
|
|
15802
|
+
// and finally close...
|
|
15803
|
+
// Assigning to local here because we will remove it from this before the
|
|
15804
|
+
// promise resolves.
|
|
15805
|
+
const { ws } = this;
|
|
15806
|
+
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15807
|
+
isClosedPromise = new Promise((resolve) => {
|
|
15808
|
+
const onclose = (event) => {
|
|
15809
|
+
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15810
|
+
resolve();
|
|
15811
|
+
};
|
|
15812
|
+
ws.onclose = onclose;
|
|
15813
|
+
// In case we don't receive close frame websocket server in time,
|
|
15814
|
+
// lets not wait for more than 1 second.
|
|
15815
|
+
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15816
|
+
});
|
|
15817
|
+
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15818
|
+
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15819
|
+
}
|
|
15820
|
+
else {
|
|
15821
|
+
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15822
|
+
isClosedPromise = Promise.resolve();
|
|
15823
|
+
}
|
|
15824
|
+
delete this.ws;
|
|
15825
|
+
return isClosedPromise;
|
|
15826
|
+
};
|
|
15827
|
+
/**
|
|
15828
|
+
* _connect - Connect to the WS endpoint
|
|
15829
|
+
*
|
|
15830
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
15831
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
15832
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
15833
|
+
* @return Promise that completes once the first health check message is received
|
|
15834
|
+
*/
|
|
15835
|
+
this._connect = async (timeoutMs) => {
|
|
15836
|
+
if (this.isConnecting)
|
|
15837
|
+
return; // ignore _connect if it's currently trying to connect
|
|
15838
|
+
this.isConnecting = true;
|
|
15839
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
15840
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
15841
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
15842
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
15843
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
15844
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
15845
|
+
// captured closure still settles only the original promise (P1) and
|
|
15846
|
+
// never poisons the newer one (P2).
|
|
15847
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15848
|
+
let isTokenReady = false;
|
|
15849
|
+
try {
|
|
15850
|
+
this._log(`_connect() - waiting for token`);
|
|
15851
|
+
await this.client.tokenManager.tokenReady();
|
|
15852
|
+
isTokenReady = true;
|
|
15853
|
+
}
|
|
15854
|
+
catch {
|
|
15855
|
+
// token provider has failed before, so try again
|
|
15856
|
+
}
|
|
15857
|
+
try {
|
|
15858
|
+
if (!isTokenReady) {
|
|
15859
|
+
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15860
|
+
await this.client.tokenManager.loadToken();
|
|
15861
|
+
}
|
|
15862
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
15863
|
+
this.client._setupConnectionIdPromise();
|
|
15864
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
15865
|
+
// closure is the one bound to the promise this attempt owns.
|
|
15866
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15867
|
+
}
|
|
15868
|
+
this._setupConnectionPromise();
|
|
15869
|
+
const wsURL = this._buildUrl();
|
|
15870
|
+
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15871
|
+
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15872
|
+
this.ws = new WS(wsURL);
|
|
15873
|
+
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15874
|
+
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15875
|
+
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15876
|
+
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15877
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
15878
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
15879
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
15880
|
+
const timers = getTimers();
|
|
15881
|
+
let handshakeTimeoutId;
|
|
15882
|
+
let response;
|
|
15883
|
+
try {
|
|
15884
|
+
response = await Promise.race([
|
|
15885
|
+
this.connectionOpen,
|
|
15886
|
+
new Promise((_, reject) => {
|
|
15887
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
15888
|
+
const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
|
|
15889
|
+
err.isWSFailure = true;
|
|
15890
|
+
reject(err);
|
|
15891
|
+
}, handshakeTimeout);
|
|
15892
|
+
}),
|
|
15893
|
+
]);
|
|
15894
|
+
}
|
|
15895
|
+
finally {
|
|
15896
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
15897
|
+
}
|
|
15898
|
+
this.isConnecting = false;
|
|
15899
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
15900
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
15901
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
15902
|
+
// a misleading "success" for a connection that has already been
|
|
15903
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
15904
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
15905
|
+
// which would observe the already-resolved connectionOpen promise
|
|
15906
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
15907
|
+
if (this.isDisconnected) {
|
|
15908
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15909
|
+
this._destroyCurrentWSConnection();
|
|
15910
|
+
}
|
|
15911
|
+
throw new Error('WS handshake aborted: disconnect() ran while connecting');
|
|
15912
|
+
}
|
|
15913
|
+
if (response) {
|
|
15914
|
+
this.connectionID = response.connection_id;
|
|
15915
|
+
this.client.resolveConnectionId?.(this.connectionID);
|
|
15916
|
+
return response;
|
|
15917
|
+
}
|
|
15918
|
+
}
|
|
15919
|
+
catch (caught) {
|
|
15920
|
+
const err = caught;
|
|
15921
|
+
this.isConnecting = false;
|
|
15922
|
+
this._log(`_connect() - Error - `, err);
|
|
15923
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
15924
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
15925
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
15926
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
15927
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
15928
|
+
// therefore fail fast instead of being orphaned.
|
|
15929
|
+
ownRejectConnectionId?.(err);
|
|
15930
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
15931
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
15932
|
+
this.rejectConnectionOpen?.(err);
|
|
15933
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
15934
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15935
|
+
this._destroyCurrentWSConnection();
|
|
15936
|
+
}
|
|
15937
|
+
throw err;
|
|
15938
|
+
}
|
|
15939
|
+
};
|
|
15247
15940
|
/**
|
|
15248
15941
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
15249
15942
|
*
|
|
15250
15943
|
* @param {Event} event Event with type online or offline
|
|
15251
|
-
*
|
|
15252
15944
|
*/
|
|
15253
15945
|
this.onlineStatusChanged = (event) => {
|
|
15254
15946
|
if (event.type === 'offline') {
|
|
@@ -15346,16 +16038,12 @@ class StableWSConnection {
|
|
|
15346
16038
|
return;
|
|
15347
16039
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
15348
16040
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
15349
|
-
// this is a permanent error raised by stream
|
|
16041
|
+
// this is a permanent error raised by stream.
|
|
15350
16042
|
// usually caused by invalid auth details
|
|
15351
16043
|
const error = new Error(`WS connection reject with error ${event.reason}`);
|
|
15352
|
-
// @ts-expect-error type issue
|
|
15353
16044
|
error.reason = event.reason;
|
|
15354
|
-
// @ts-expect-error type issue
|
|
15355
16045
|
error.code = event.code;
|
|
15356
|
-
// @ts-expect-error type issue
|
|
15357
16046
|
error.wasClean = event.wasClean;
|
|
15358
|
-
// @ts-expect-error type issue
|
|
15359
16047
|
error.target = event.target;
|
|
15360
16048
|
this.rejectConnectionOpen?.(error);
|
|
15361
16049
|
this._log(`onclose() - WS connection reject with error ${event.reason}`, {
|
|
@@ -15493,205 +16181,8 @@ class StableWSConnection {
|
|
|
15493
16181
|
}, this.connectionCheckTimeout);
|
|
15494
16182
|
};
|
|
15495
16183
|
this.client = client;
|
|
15496
|
-
/** consecutive failures influence the duration of the timeout */
|
|
15497
|
-
this.consecutiveFailures = 0;
|
|
15498
|
-
/** keep track of the total number of failures */
|
|
15499
|
-
this.totalFailures = 0;
|
|
15500
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15501
|
-
this.isConnecting = false;
|
|
15502
|
-
/** To avoid reconnect if client is disconnected */
|
|
15503
|
-
this.isDisconnected = false;
|
|
15504
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
15505
|
-
this.isConnectionOpenResolved = false;
|
|
15506
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
15507
|
-
this.isHealthy = false;
|
|
15508
|
-
/** Incremented when a new WS connection is made */
|
|
15509
|
-
this.wsID = 1;
|
|
15510
|
-
/** Store the last event time for health checks */
|
|
15511
|
-
this.lastEvent = null;
|
|
15512
|
-
/** Send a health check message every 25 seconds */
|
|
15513
|
-
this.pingInterval = 25 * 1000;
|
|
15514
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15515
16184
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
15516
16185
|
}
|
|
15517
|
-
/**
|
|
15518
|
-
* connect - Connect to the WS URL
|
|
15519
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15520
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15521
|
-
*/
|
|
15522
|
-
async connect(timeout = 15000) {
|
|
15523
|
-
if (this.isConnecting) {
|
|
15524
|
-
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15525
|
-
}
|
|
15526
|
-
this.isDisconnected = false;
|
|
15527
|
-
try {
|
|
15528
|
-
const healthCheck = await this._connect();
|
|
15529
|
-
this.consecutiveFailures = 0;
|
|
15530
|
-
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15531
|
-
}
|
|
15532
|
-
catch (error) {
|
|
15533
|
-
this.isHealthy = false;
|
|
15534
|
-
this.consecutiveFailures += 1;
|
|
15535
|
-
if (
|
|
15536
|
-
// @ts-expect-error type issue
|
|
15537
|
-
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15538
|
-
!this.client.tokenManager.isStatic()) {
|
|
15539
|
-
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15540
|
-
this._reconnect({ refreshToken: true });
|
|
15541
|
-
}
|
|
15542
|
-
else {
|
|
15543
|
-
// @ts-expect-error type issue
|
|
15544
|
-
if (!error.isWSFailure) {
|
|
15545
|
-
// API rejected the connection and we should not retry
|
|
15546
|
-
throw new Error(JSON.stringify({
|
|
15547
|
-
// @ts-expect-error type issue
|
|
15548
|
-
code: error.code,
|
|
15549
|
-
// @ts-expect-error type issue
|
|
15550
|
-
StatusCode: error.StatusCode,
|
|
15551
|
-
// @ts-expect-error type issue
|
|
15552
|
-
message: error.message,
|
|
15553
|
-
// @ts-expect-error type issue
|
|
15554
|
-
isWSFailure: error.isWSFailure,
|
|
15555
|
-
}));
|
|
15556
|
-
}
|
|
15557
|
-
}
|
|
15558
|
-
}
|
|
15559
|
-
return await this._waitForHealthy(timeout);
|
|
15560
|
-
}
|
|
15561
|
-
/**
|
|
15562
|
-
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15563
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15564
|
-
* @param timeout duration(ms)
|
|
15565
|
-
*/
|
|
15566
|
-
async _waitForHealthy(timeout = 15000) {
|
|
15567
|
-
return Promise.race([
|
|
15568
|
-
(async () => {
|
|
15569
|
-
const interval = 50; // ms
|
|
15570
|
-
for (let i = 0; i <= timeout; i += interval) {
|
|
15571
|
-
try {
|
|
15572
|
-
return await this.connectionOpen;
|
|
15573
|
-
}
|
|
15574
|
-
catch (error) {
|
|
15575
|
-
if (i === timeout) {
|
|
15576
|
-
throw new Error(JSON.stringify({
|
|
15577
|
-
code: error.code,
|
|
15578
|
-
StatusCode: error.StatusCode,
|
|
15579
|
-
message: error.message,
|
|
15580
|
-
isWSFailure: error.isWSFailure,
|
|
15581
|
-
}));
|
|
15582
|
-
}
|
|
15583
|
-
await sleep(interval);
|
|
15584
|
-
}
|
|
15585
|
-
}
|
|
15586
|
-
})(),
|
|
15587
|
-
(async () => {
|
|
15588
|
-
await sleep(timeout);
|
|
15589
|
-
this.isConnecting = false;
|
|
15590
|
-
throw new Error(JSON.stringify({
|
|
15591
|
-
code: '',
|
|
15592
|
-
StatusCode: '',
|
|
15593
|
-
message: 'initial WS connection could not be established',
|
|
15594
|
-
isWSFailure: true,
|
|
15595
|
-
}));
|
|
15596
|
-
})(),
|
|
15597
|
-
]);
|
|
15598
|
-
}
|
|
15599
|
-
/**
|
|
15600
|
-
* disconnect - Disconnect the connection and doesn't recover...
|
|
15601
|
-
*
|
|
15602
|
-
*/
|
|
15603
|
-
disconnect(timeout) {
|
|
15604
|
-
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15605
|
-
this.wsID += 1;
|
|
15606
|
-
this.isConnecting = false;
|
|
15607
|
-
this.isDisconnected = true;
|
|
15608
|
-
// start by removing all the listeners
|
|
15609
|
-
if (this.healthCheckTimeoutRef) {
|
|
15610
|
-
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15611
|
-
}
|
|
15612
|
-
if (this.connectionCheckTimeoutRef) {
|
|
15613
|
-
clearInterval(this.connectionCheckTimeoutRef);
|
|
15614
|
-
}
|
|
15615
|
-
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15616
|
-
this.isHealthy = false;
|
|
15617
|
-
let isClosedPromise;
|
|
15618
|
-
// and finally close...
|
|
15619
|
-
// Assigning to local here because we will remove it from this before the
|
|
15620
|
-
// promise resolves.
|
|
15621
|
-
const { ws } = this;
|
|
15622
|
-
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15623
|
-
isClosedPromise = new Promise((resolve) => {
|
|
15624
|
-
const onclose = (event) => {
|
|
15625
|
-
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15626
|
-
resolve();
|
|
15627
|
-
};
|
|
15628
|
-
ws.onclose = onclose;
|
|
15629
|
-
// In case we don't receive close frame websocket server in time,
|
|
15630
|
-
// lets not wait for more than 1 second.
|
|
15631
|
-
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15632
|
-
});
|
|
15633
|
-
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15634
|
-
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15635
|
-
}
|
|
15636
|
-
else {
|
|
15637
|
-
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15638
|
-
isClosedPromise = Promise.resolve();
|
|
15639
|
-
}
|
|
15640
|
-
delete this.ws;
|
|
15641
|
-
return isClosedPromise;
|
|
15642
|
-
}
|
|
15643
|
-
/**
|
|
15644
|
-
* _connect - Connect to the WS endpoint
|
|
15645
|
-
*
|
|
15646
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15647
|
-
*/
|
|
15648
|
-
async _connect() {
|
|
15649
|
-
if (this.isConnecting)
|
|
15650
|
-
return; // ignore _connect if it's currently trying to connect
|
|
15651
|
-
this.isConnecting = true;
|
|
15652
|
-
let isTokenReady = false;
|
|
15653
|
-
try {
|
|
15654
|
-
this._log(`_connect() - waiting for token`);
|
|
15655
|
-
await this.client.tokenManager.tokenReady();
|
|
15656
|
-
isTokenReady = true;
|
|
15657
|
-
}
|
|
15658
|
-
catch {
|
|
15659
|
-
// token provider has failed before, so try again
|
|
15660
|
-
}
|
|
15661
|
-
try {
|
|
15662
|
-
if (!isTokenReady) {
|
|
15663
|
-
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15664
|
-
await this.client.tokenManager.loadToken();
|
|
15665
|
-
}
|
|
15666
|
-
if (!this.client.isConnectionIsPromisePending) {
|
|
15667
|
-
this.client._setupConnectionIdPromise();
|
|
15668
|
-
}
|
|
15669
|
-
this._setupConnectionPromise();
|
|
15670
|
-
const wsURL = this._buildUrl();
|
|
15671
|
-
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15672
|
-
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15673
|
-
this.ws = new WS(wsURL);
|
|
15674
|
-
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15675
|
-
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15676
|
-
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15677
|
-
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15678
|
-
const response = await this.connectionOpen;
|
|
15679
|
-
this.isConnecting = false;
|
|
15680
|
-
if (response) {
|
|
15681
|
-
this.connectionID = response.connection_id;
|
|
15682
|
-
this.client.resolveConnectionId?.(this.connectionID);
|
|
15683
|
-
return response;
|
|
15684
|
-
}
|
|
15685
|
-
}
|
|
15686
|
-
catch (err) {
|
|
15687
|
-
this.client._setupConnectionIdPromise();
|
|
15688
|
-
this.isConnecting = false;
|
|
15689
|
-
// @ts-expect-error type issue
|
|
15690
|
-
this._log(`_connect() - Error - `, err);
|
|
15691
|
-
this.client.rejectConnectionId?.(err);
|
|
15692
|
-
throw err;
|
|
15693
|
-
}
|
|
15694
|
-
}
|
|
15695
16186
|
/**
|
|
15696
16187
|
* _reconnect - Retry the connection to WS endpoint
|
|
15697
16188
|
*
|
|
@@ -15738,7 +16229,8 @@ class StableWSConnection {
|
|
|
15738
16229
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
15739
16230
|
this.consecutiveFailures = 0;
|
|
15740
16231
|
}
|
|
15741
|
-
catch (
|
|
16232
|
+
catch (caught) {
|
|
16233
|
+
const error = caught;
|
|
15742
16234
|
this.isHealthy = false;
|
|
15743
16235
|
this.consecutiveFailures += 1;
|
|
15744
16236
|
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
@@ -16295,7 +16787,7 @@ class StreamClient {
|
|
|
16295
16787
|
this.getUserAgent = () => {
|
|
16296
16788
|
if (!this.cachedUserAgent) {
|
|
16297
16789
|
const { clientAppIdentifier = {} } = this.options;
|
|
16298
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
16790
|
+
const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
|
|
16299
16791
|
this.cachedUserAgent = [
|
|
16300
16792
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16301
16793
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16403,7 +16895,7 @@ class StreamClient {
|
|
|
16403
16895
|
get connectionIdPromise() {
|
|
16404
16896
|
return this.connectionIdPromiseSafe?.();
|
|
16405
16897
|
}
|
|
16406
|
-
get
|
|
16898
|
+
get isConnectionIdPromisePending() {
|
|
16407
16899
|
return this.connectionIdPromiseSafe?.checkPending() ?? false;
|
|
16408
16900
|
}
|
|
16409
16901
|
get wsPromise() {
|