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