@stream-io/video-client 1.48.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 +25 -0
- package/dist/index.browser.es.js +1497 -677
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1497 -677
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1497 -677
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +77 -4
- package/dist/src/StreamSfuClient.d.ts +8 -1
- 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 +13 -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/SlidingWindowRateLimiter.d.ts +28 -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/BasePeerConnection.d.ts +11 -2
- 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/rtc/index.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +33 -1
- 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 +268 -40
- package/src/StreamSfuClient.ts +75 -12
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/__tests__/Call.publishing.test.ts +103 -0
- package/src/__tests__/StreamSfuClient.test.ts +275 -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 +20 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devices.ts +2 -1
- 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/SlidingWindowRateLimiter.ts +49 -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__/SlidingWindowRateLimiter.test.ts +43 -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/rpc/retryable.ts +0 -1
- package/src/rtc/BasePeerConnection.ts +96 -6
- package/src/rtc/Publisher.ts +49 -2
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
- package/src/rtc/__tests__/Publisher.test.ts +332 -10
- package/src/rtc/__tests__/Subscriber.test.ts +202 -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/rtc/index.ts +1 -0
- package/src/rtc/types.ts +38 -1
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
package/dist/index.cjs.js
CHANGED
|
@@ -1418,6 +1418,35 @@ var ClientCapability;
|
|
|
1418
1418
|
*/
|
|
1419
1419
|
ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
|
|
1420
1420
|
})(ClientCapability || (ClientCapability = {}));
|
|
1421
|
+
/**
|
|
1422
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1423
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1424
|
+
*
|
|
1425
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1426
|
+
*/
|
|
1427
|
+
var DegradationPreference;
|
|
1428
|
+
(function (DegradationPreference) {
|
|
1429
|
+
/**
|
|
1430
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1431
|
+
*/
|
|
1432
|
+
DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
1433
|
+
/**
|
|
1434
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1435
|
+
*/
|
|
1436
|
+
DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
|
|
1437
|
+
/**
|
|
1438
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1439
|
+
*/
|
|
1440
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
|
|
1441
|
+
/**
|
|
1442
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1443
|
+
*/
|
|
1444
|
+
DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
|
|
1445
|
+
/**
|
|
1446
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1447
|
+
*/
|
|
1448
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
|
|
1449
|
+
})(DegradationPreference || (DegradationPreference = {}));
|
|
1421
1450
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1422
1451
|
class CallState$Type extends runtime.MessageType {
|
|
1423
1452
|
constructor() {
|
|
@@ -1687,6 +1716,16 @@ class PublishOption$Type extends runtime.MessageType {
|
|
|
1687
1716
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1688
1717
|
T: () => AudioBitrate,
|
|
1689
1718
|
},
|
|
1719
|
+
{
|
|
1720
|
+
no: 11,
|
|
1721
|
+
name: 'degradation_preference',
|
|
1722
|
+
kind: 'enum',
|
|
1723
|
+
T: () => [
|
|
1724
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1725
|
+
DegradationPreference,
|
|
1726
|
+
'DEGRADATION_PREFERENCE_',
|
|
1727
|
+
],
|
|
1728
|
+
},
|
|
1690
1729
|
]);
|
|
1691
1730
|
}
|
|
1692
1731
|
}
|
|
@@ -2133,6 +2172,7 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
2133
2172
|
ClientDetails: ClientDetails,
|
|
2134
2173
|
Codec: Codec,
|
|
2135
2174
|
get ConnectionQuality () { return ConnectionQuality; },
|
|
2175
|
+
get DegradationPreference () { return DegradationPreference; },
|
|
2136
2176
|
Device: Device,
|
|
2137
2177
|
Error: Error$2,
|
|
2138
2178
|
get ErrorCode () { return ErrorCode; },
|
|
@@ -3520,6 +3560,16 @@ class VideoSender$Type extends runtime.MessageType {
|
|
|
3520
3560
|
kind: 'scalar',
|
|
3521
3561
|
T: 5 /*ScalarType.INT32*/,
|
|
3522
3562
|
},
|
|
3563
|
+
{
|
|
3564
|
+
no: 6,
|
|
3565
|
+
name: 'degradation_preference',
|
|
3566
|
+
kind: 'enum',
|
|
3567
|
+
T: () => [
|
|
3568
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
3569
|
+
DegradationPreference,
|
|
3570
|
+
'DEGRADATION_PREFERENCE_',
|
|
3571
|
+
],
|
|
3572
|
+
},
|
|
3523
3573
|
]);
|
|
3524
3574
|
}
|
|
3525
3575
|
}
|
|
@@ -3885,6 +3935,18 @@ const createSignalClient = (options) => {
|
|
|
3885
3935
|
};
|
|
3886
3936
|
|
|
3887
3937
|
const sleep = (m) => new Promise((r) => setTimeout(r, m));
|
|
3938
|
+
const timeboxed = async (promises, ms) => {
|
|
3939
|
+
let timerId;
|
|
3940
|
+
const timeout = new Promise((_, reject) => {
|
|
3941
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
3942
|
+
});
|
|
3943
|
+
try {
|
|
3944
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
3945
|
+
}
|
|
3946
|
+
finally {
|
|
3947
|
+
clearTimeout(timerId);
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3888
3950
|
function isFunction(value) {
|
|
3889
3951
|
return (value &&
|
|
3890
3952
|
(Object.prototype.toString.call(value) === '[object Function]' ||
|
|
@@ -4026,8 +4088,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
|
|
|
4026
4088
|
do {
|
|
4027
4089
|
if (attempt > 0)
|
|
4028
4090
|
await sleep(retryInterval(attempt));
|
|
4029
|
-
if (signal?.aborted)
|
|
4030
|
-
throw new Error(signal.reason);
|
|
4031
4091
|
try {
|
|
4032
4092
|
result = await rpc({ attempt });
|
|
4033
4093
|
}
|
|
@@ -4425,6 +4485,21 @@ class Dispatcher {
|
|
|
4425
4485
|
}
|
|
4426
4486
|
}
|
|
4427
4487
|
|
|
4488
|
+
/**
|
|
4489
|
+
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
4490
|
+
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
4491
|
+
*/
|
|
4492
|
+
class NegotiationError extends Error {
|
|
4493
|
+
/**
|
|
4494
|
+
* Creates an instance of NegotiationError.
|
|
4495
|
+
*/
|
|
4496
|
+
constructor(error) {
|
|
4497
|
+
super(error.message);
|
|
4498
|
+
this.name = 'NegotiationError';
|
|
4499
|
+
this.error = error;
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
|
|
4428
4503
|
/**
|
|
4429
4504
|
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
4430
4505
|
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
@@ -4611,6 +4686,20 @@ const setCurrentValue = (subject, update) => {
|
|
|
4611
4686
|
subject.next(next);
|
|
4612
4687
|
return next;
|
|
4613
4688
|
};
|
|
4689
|
+
/**
|
|
4690
|
+
* Updates the value of the provided Subject asynchronously.
|
|
4691
|
+
* Locks the subject to prevent concurrent updates.
|
|
4692
|
+
*
|
|
4693
|
+
* @param subject the subject to update.
|
|
4694
|
+
* @param update the update to apply to the subject.
|
|
4695
|
+
*/
|
|
4696
|
+
const setCurrentValueAsync = async (subject, update) => {
|
|
4697
|
+
return withoutConcurrency(subject, async () => {
|
|
4698
|
+
const next = await update(getCurrentValue(subject));
|
|
4699
|
+
subject.next(next);
|
|
4700
|
+
return next;
|
|
4701
|
+
});
|
|
4702
|
+
};
|
|
4614
4703
|
/**
|
|
4615
4704
|
* Updates the value of the provided Subject and returns the previous value
|
|
4616
4705
|
* and a function to roll back the update.
|
|
@@ -4665,6 +4754,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
|
|
|
4665
4754
|
createSubscription: createSubscription,
|
|
4666
4755
|
getCurrentValue: getCurrentValue,
|
|
4667
4756
|
setCurrentValue: setCurrentValue,
|
|
4757
|
+
setCurrentValueAsync: setCurrentValueAsync,
|
|
4668
4758
|
updateValue: updateValue
|
|
4669
4759
|
});
|
|
4670
4760
|
|
|
@@ -6242,21 +6332,6 @@ class CallState {
|
|
|
6242
6332
|
}
|
|
6243
6333
|
}
|
|
6244
6334
|
|
|
6245
|
-
/**
|
|
6246
|
-
* NegotiationError is thrown when there is an error during the negotiation process.
|
|
6247
|
-
* It extends the built-in Error class and includes an SfuError object for more details.
|
|
6248
|
-
*/
|
|
6249
|
-
class NegotiationError extends Error {
|
|
6250
|
-
/**
|
|
6251
|
-
* Creates an instance of NegotiationError.
|
|
6252
|
-
*/
|
|
6253
|
-
constructor(error) {
|
|
6254
|
-
super(error.message);
|
|
6255
|
-
this.name = 'NegotiationError';
|
|
6256
|
-
this.error = error;
|
|
6257
|
-
}
|
|
6258
|
-
}
|
|
6259
|
-
|
|
6260
6335
|
/**
|
|
6261
6336
|
* Flatten the stats report into an array of stats objects.
|
|
6262
6337
|
*
|
|
@@ -6304,7 +6379,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6304
6379
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6305
6380
|
};
|
|
6306
6381
|
|
|
6307
|
-
const version = "1.
|
|
6382
|
+
const version = "1.50.0";
|
|
6308
6383
|
const [major, minor, patch] = version.split('.');
|
|
6309
6384
|
let sdkInfo = {
|
|
6310
6385
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -6457,6 +6532,31 @@ const isSafari = () => {
|
|
|
6457
6532
|
return false;
|
|
6458
6533
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
6459
6534
|
};
|
|
6535
|
+
/**
|
|
6536
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
6537
|
+
*
|
|
6538
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
6539
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
6540
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
6541
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
6542
|
+
* share the underlying WebKit quirks.
|
|
6543
|
+
*
|
|
6544
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
6545
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
6546
|
+
*/
|
|
6547
|
+
const isWebKit = () => {
|
|
6548
|
+
if (typeof navigator === 'undefined')
|
|
6549
|
+
return false;
|
|
6550
|
+
const ua = navigator.userAgent || '';
|
|
6551
|
+
if (!/AppleWebKit\//.test(ua))
|
|
6552
|
+
return false;
|
|
6553
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
6554
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
6555
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
6556
|
+
// the mobile Blink stack.
|
|
6557
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
6558
|
+
return !regExp.test(ua);
|
|
6559
|
+
};
|
|
6460
6560
|
/**
|
|
6461
6561
|
* Checks whether the current browser is Firefox.
|
|
6462
6562
|
*/
|
|
@@ -6500,7 +6600,8 @@ var browsers = /*#__PURE__*/Object.freeze({
|
|
|
6500
6600
|
isChrome: isChrome,
|
|
6501
6601
|
isFirefox: isFirefox,
|
|
6502
6602
|
isSafari: isSafari,
|
|
6503
|
-
isSupportedBrowser: isSupportedBrowser
|
|
6603
|
+
isSupportedBrowser: isSupportedBrowser,
|
|
6604
|
+
isWebKit: isWebKit
|
|
6504
6605
|
});
|
|
6505
6606
|
|
|
6506
6607
|
/**
|
|
@@ -7326,6 +7427,30 @@ class Tracer {
|
|
|
7326
7427
|
}
|
|
7327
7428
|
}
|
|
7328
7429
|
|
|
7430
|
+
/**
|
|
7431
|
+
* Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
|
|
7432
|
+
* are still accepted at the callback boundary (e.g. when forwarding an SFU
|
|
7433
|
+
* error message), but only the members below influence reconnect-loop
|
|
7434
|
+
* behavior. In particular, `Call.reconnect` programmatically inspects
|
|
7435
|
+
* `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
|
|
7436
|
+
* canonical member when you want the SDK to react to the reason; pass a
|
|
7437
|
+
* free-form string when the value is purely diagnostic.
|
|
7438
|
+
*/
|
|
7439
|
+
const ReconnectReason = {
|
|
7440
|
+
/** ICE never reached `connected`/`completed`, escalate to REJOIN. */
|
|
7441
|
+
ICE_NEVER_CONNECTED: 'ice_never_connected',
|
|
7442
|
+
/** RTCPeerConnection.connectionState became `failed`. */
|
|
7443
|
+
CONNECTION_FAILED: 'connection_failed',
|
|
7444
|
+
/** `restartIce()` rejected. */
|
|
7445
|
+
RESTART_ICE_FAILED: 'restart_ice_failed',
|
|
7446
|
+
/** SFU `goAway` event, migrate to a new SFU. */
|
|
7447
|
+
GO_AWAY: 'go_away',
|
|
7448
|
+
/** Network came back online after going offline. */
|
|
7449
|
+
NETWORK_BACK_ONLINE: 'network_back_online',
|
|
7450
|
+
/** SFU error event with no descriptive message. */
|
|
7451
|
+
SFU_ERROR: 'sfu_error',
|
|
7452
|
+
};
|
|
7453
|
+
|
|
7329
7454
|
/**
|
|
7330
7455
|
* A base class for the `Publisher` and `Subscriber` classes.
|
|
7331
7456
|
* @internal
|
|
@@ -7334,7 +7459,8 @@ class BasePeerConnection {
|
|
|
7334
7459
|
/**
|
|
7335
7460
|
* Constructs a new `BasePeerConnection` instance.
|
|
7336
7461
|
*/
|
|
7337
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7462
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
|
|
7463
|
+
this.iceHasEverConnected = false;
|
|
7338
7464
|
this.isIceRestarting = false;
|
|
7339
7465
|
this.isDisposed = false;
|
|
7340
7466
|
this.trackIdToTrackType = new Map();
|
|
@@ -7357,13 +7483,12 @@ class BasePeerConnection {
|
|
|
7357
7483
|
*/
|
|
7358
7484
|
this.tryRestartIce = () => {
|
|
7359
7485
|
this.restartIce().catch((e) => {
|
|
7360
|
-
|
|
7361
|
-
this.logger.error(reason, e);
|
|
7486
|
+
this.logger.error('restartICE() failed, initiating reconnect', e);
|
|
7362
7487
|
const strategy = e instanceof NegotiationError &&
|
|
7363
7488
|
e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
|
|
7364
7489
|
? WebsocketReconnectStrategy.FAST
|
|
7365
7490
|
: WebsocketReconnectStrategy.REJOIN;
|
|
7366
|
-
this.onReconnectionNeeded?.(strategy,
|
|
7491
|
+
this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
|
|
7367
7492
|
});
|
|
7368
7493
|
};
|
|
7369
7494
|
/**
|
|
@@ -7432,6 +7557,17 @@ class BasePeerConnection {
|
|
|
7432
7557
|
const connectionState = this.pc.connectionState;
|
|
7433
7558
|
return !failedStates.has(iceState) && !failedStates.has(connectionState);
|
|
7434
7559
|
};
|
|
7560
|
+
/**
|
|
7561
|
+
* Returns true only when the peer connection is currently fully established
|
|
7562
|
+
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
7563
|
+
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
7564
|
+
*/
|
|
7565
|
+
this.isStable = () => {
|
|
7566
|
+
const iceState = this.pc.iceConnectionState;
|
|
7567
|
+
const connectionState = this.pc.connectionState;
|
|
7568
|
+
return ((iceState === 'connected' || iceState === 'completed') &&
|
|
7569
|
+
connectionState === 'connected');
|
|
7570
|
+
};
|
|
7435
7571
|
/**
|
|
7436
7572
|
* Handles the ICECandidate event and
|
|
7437
7573
|
* Initiates an ICE Trickle process with the SFU.
|
|
@@ -7481,7 +7617,7 @@ class BasePeerConnection {
|
|
|
7481
7617
|
}
|
|
7482
7618
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
7483
7619
|
if (state === 'failed') {
|
|
7484
|
-
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN,
|
|
7620
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
|
|
7485
7621
|
return;
|
|
7486
7622
|
}
|
|
7487
7623
|
this.handleConnectionStateUpdate(state);
|
|
@@ -7503,6 +7639,41 @@ class BasePeerConnection {
|
|
|
7503
7639
|
// do nothing when ICE is restarting
|
|
7504
7640
|
if (this.isIceRestarting)
|
|
7505
7641
|
return;
|
|
7642
|
+
// Pre-connect handling: ICE has never reached `connected`/`completed`.
|
|
7643
|
+
// Restart is futile here (the data plane was never established), but
|
|
7644
|
+
// these two terminal-ish states need different treatment:
|
|
7645
|
+
// - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
|
|
7646
|
+
// /PC configuration gets a chance, and let `Call.reconnect` count
|
|
7647
|
+
// this toward the unsupported-network budget.
|
|
7648
|
+
// - `disconnected` is transient, the browser may yet move back to
|
|
7649
|
+
// `checking`/`connected`. Don't restart, don't escalate; wait it
|
|
7650
|
+
// out. If it ultimately fails, ICE will transition to `failed` and
|
|
7651
|
+
// the branch above will take over.
|
|
7652
|
+
if (!this.iceHasEverConnected) {
|
|
7653
|
+
if (state === 'failed') {
|
|
7654
|
+
this.logger.info('ICE failed before connected, escalating to REJOIN');
|
|
7655
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7656
|
+
this.preConnectStuckTimeout = undefined;
|
|
7657
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7658
|
+
return;
|
|
7659
|
+
}
|
|
7660
|
+
if (state === 'disconnected') {
|
|
7661
|
+
this.logger.info('ICE disconnected before connected, wait to recover');
|
|
7662
|
+
// Watchdog: if the browser stays in `disconnected` without ever
|
|
7663
|
+
// reaching `connected` or transitioning to `failed`, escalate to
|
|
7664
|
+
// REJOIN ourselves so we don't wait silently forever. Rare but
|
|
7665
|
+
// observed on flaky mobile networks.
|
|
7666
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7667
|
+
this.preConnectStuckTimeout = setTimeout(() => {
|
|
7668
|
+
if (!this.iceHasEverConnected &&
|
|
7669
|
+
this.pc.iceConnectionState === 'disconnected') {
|
|
7670
|
+
this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
|
|
7671
|
+
this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
|
|
7672
|
+
}
|
|
7673
|
+
}, this.iceRestartDelay * 2);
|
|
7674
|
+
return;
|
|
7675
|
+
}
|
|
7676
|
+
}
|
|
7506
7677
|
switch (state) {
|
|
7507
7678
|
case 'failed':
|
|
7508
7679
|
// in the `failed` state, we try to restart ICE immediately
|
|
@@ -7522,12 +7693,24 @@ class BasePeerConnection {
|
|
|
7522
7693
|
}, this.iceRestartDelay);
|
|
7523
7694
|
break;
|
|
7524
7695
|
case 'connected':
|
|
7525
|
-
|
|
7696
|
+
case 'completed':
|
|
7697
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
7698
|
+
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
7699
|
+
// Used by `Call` to reset the unsupported-network failure counter
|
|
7700
|
+
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
7701
|
+
if (!this.iceHasEverConnected) {
|
|
7702
|
+
this.iceHasEverConnected = true;
|
|
7703
|
+
this.onIceConnected?.(this.peerType);
|
|
7704
|
+
}
|
|
7705
|
+
// clear any scheduled restartICE since the connection is healthy
|
|
7526
7706
|
if (this.iceRestartTimeout) {
|
|
7527
7707
|
this.logger.info('connected connection, canceling restartICE');
|
|
7528
7708
|
clearTimeout(this.iceRestartTimeout);
|
|
7529
7709
|
this.iceRestartTimeout = undefined;
|
|
7530
7710
|
}
|
|
7711
|
+
// clear the pre-connect watchdog if it was armed
|
|
7712
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7713
|
+
this.preConnectStuckTimeout = undefined;
|
|
7531
7714
|
break;
|
|
7532
7715
|
}
|
|
7533
7716
|
};
|
|
@@ -7560,6 +7743,7 @@ class BasePeerConnection {
|
|
|
7560
7743
|
this.clientPublishOptions = clientPublishOptions;
|
|
7561
7744
|
this.tag = tag;
|
|
7562
7745
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
7746
|
+
this.onIceConnected = onIceConnected;
|
|
7563
7747
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
7564
7748
|
this.pc = this.createPeerConnection(connectionConfig);
|
|
7565
7749
|
this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
|
|
@@ -7578,7 +7762,10 @@ class BasePeerConnection {
|
|
|
7578
7762
|
dispose() {
|
|
7579
7763
|
clearTimeout(this.iceRestartTimeout);
|
|
7580
7764
|
this.iceRestartTimeout = undefined;
|
|
7765
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
7766
|
+
this.preConnectStuckTimeout = undefined;
|
|
7581
7767
|
this.onReconnectionNeeded = undefined;
|
|
7768
|
+
this.onIceConnected = undefined;
|
|
7582
7769
|
this.isDisposed = true;
|
|
7583
7770
|
this.detachEventHandlers();
|
|
7584
7771
|
this.pc.close();
|
|
@@ -7890,6 +8077,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
|
|
|
7890
8077
|
}));
|
|
7891
8078
|
};
|
|
7892
8079
|
|
|
8080
|
+
const toRTCDegradationPreference = (preference) => {
|
|
8081
|
+
switch (preference) {
|
|
8082
|
+
case DegradationPreference.BALANCED:
|
|
8083
|
+
return 'balanced';
|
|
8084
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
8085
|
+
return 'maintain-framerate';
|
|
8086
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
8087
|
+
return 'maintain-resolution';
|
|
8088
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
8089
|
+
// @ts-expect-error not in the typedefs yet
|
|
8090
|
+
return 'maintain-framerate-and-resolution';
|
|
8091
|
+
case DegradationPreference.UNSPECIFIED:
|
|
8092
|
+
return undefined;
|
|
8093
|
+
default:
|
|
8094
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8095
|
+
}
|
|
8096
|
+
};
|
|
8097
|
+
|
|
7893
8098
|
/**
|
|
7894
8099
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
7895
8100
|
*
|
|
@@ -7951,7 +8156,9 @@ class Publisher extends BasePeerConnection {
|
|
|
7951
8156
|
sendEncodings,
|
|
7952
8157
|
});
|
|
7953
8158
|
const params = transceiver.sender.getParameters();
|
|
7954
|
-
params.degradationPreference =
|
|
8159
|
+
params.degradationPreference =
|
|
8160
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
8161
|
+
'maintain-framerate';
|
|
7955
8162
|
await transceiver.sender.setParameters(params);
|
|
7956
8163
|
const trackType = publishOption.trackType;
|
|
7957
8164
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
@@ -8048,6 +8255,40 @@ class Publisher extends BasePeerConnection {
|
|
|
8048
8255
|
}
|
|
8049
8256
|
return false;
|
|
8050
8257
|
};
|
|
8258
|
+
/**
|
|
8259
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
8260
|
+
* reattaching the currently published track on each matching sender.
|
|
8261
|
+
*
|
|
8262
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
8263
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
8264
|
+
* can stop producing RTP packets even though the underlying
|
|
8265
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
8266
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
8267
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
8268
|
+
* flow with the same SSRC.
|
|
8269
|
+
*
|
|
8270
|
+
* No-op when nothing is published for the given track type.
|
|
8271
|
+
*
|
|
8272
|
+
* @param trackType the track type to refresh.
|
|
8273
|
+
*/
|
|
8274
|
+
this.refreshTrack = async (trackType) => {
|
|
8275
|
+
for (const item of this.transceiverCache.items()) {
|
|
8276
|
+
if (item.publishOption.trackType !== trackType)
|
|
8277
|
+
continue;
|
|
8278
|
+
const { sender } = item.transceiver;
|
|
8279
|
+
const track = sender.track;
|
|
8280
|
+
if (!track || track.readyState !== 'live')
|
|
8281
|
+
continue;
|
|
8282
|
+
try {
|
|
8283
|
+
await sender.replaceTrack(null);
|
|
8284
|
+
await sender.replaceTrack(track);
|
|
8285
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
8286
|
+
}
|
|
8287
|
+
catch (err) {
|
|
8288
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
8289
|
+
}
|
|
8290
|
+
}
|
|
8291
|
+
};
|
|
8051
8292
|
/**
|
|
8052
8293
|
* Stops the cloned track that is being published to the SFU.
|
|
8053
8294
|
*/
|
|
@@ -8125,6 +8366,12 @@ class Publisher extends BasePeerConnection {
|
|
|
8125
8366
|
changed = true;
|
|
8126
8367
|
}
|
|
8127
8368
|
}
|
|
8369
|
+
const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
|
|
8370
|
+
if (degradationPreference &&
|
|
8371
|
+
params.degradationPreference !== degradationPreference) {
|
|
8372
|
+
params.degradationPreference = degradationPreference;
|
|
8373
|
+
changed = true;
|
|
8374
|
+
}
|
|
8128
8375
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
8129
8376
|
if (!changed) {
|
|
8130
8377
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -8232,7 +8479,8 @@ class Publisher extends BasePeerConnection {
|
|
|
8232
8479
|
const trackInfos = [];
|
|
8233
8480
|
for (const publishOption of this.publishOptions) {
|
|
8234
8481
|
const bundle = this.transceiverCache.get(publishOption);
|
|
8235
|
-
|
|
8482
|
+
const track = bundle?.transceiver.sender.track;
|
|
8483
|
+
if (!bundle || !track || track.readyState !== 'live')
|
|
8236
8484
|
continue;
|
|
8237
8485
|
trackInfos.push(this.toTrackInfo(bundle, sdp));
|
|
8238
8486
|
}
|
|
@@ -8304,6 +8552,36 @@ class Publisher extends BasePeerConnection {
|
|
|
8304
8552
|
}
|
|
8305
8553
|
}
|
|
8306
8554
|
|
|
8555
|
+
/**
|
|
8556
|
+
* Adds unique values to an array.
|
|
8557
|
+
*
|
|
8558
|
+
* @param arr the array to add to.
|
|
8559
|
+
* @param values the values to add.
|
|
8560
|
+
*/
|
|
8561
|
+
const pushToIfMissing = (arr, ...values) => {
|
|
8562
|
+
for (const v of values) {
|
|
8563
|
+
if (!arr.includes(v)) {
|
|
8564
|
+
arr.push(v);
|
|
8565
|
+
}
|
|
8566
|
+
}
|
|
8567
|
+
return arr;
|
|
8568
|
+
};
|
|
8569
|
+
/**
|
|
8570
|
+
* Removes values from an array if they are present.
|
|
8571
|
+
*
|
|
8572
|
+
* @param arr the array to remove from.
|
|
8573
|
+
* @param values the values to remove.
|
|
8574
|
+
*/
|
|
8575
|
+
const removeFromIfPresent = (arr, ...values) => {
|
|
8576
|
+
for (const v of values) {
|
|
8577
|
+
const index = arr.indexOf(v);
|
|
8578
|
+
if (index !== -1) {
|
|
8579
|
+
arr.splice(index, 1);
|
|
8580
|
+
}
|
|
8581
|
+
}
|
|
8582
|
+
return arr;
|
|
8583
|
+
};
|
|
8584
|
+
|
|
8307
8585
|
/**
|
|
8308
8586
|
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
8309
8587
|
* media streams from the SFU.
|
|
@@ -8345,27 +8623,34 @@ class Subscriber extends BasePeerConnection {
|
|
|
8345
8623
|
}
|
|
8346
8624
|
};
|
|
8347
8625
|
this.handleOnTrack = (e) => {
|
|
8348
|
-
const
|
|
8626
|
+
const { streams, track } = e;
|
|
8627
|
+
const [primaryStream] = streams;
|
|
8349
8628
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
8350
8629
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
8351
8630
|
const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8352
|
-
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
8631
|
+
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
|
|
8632
|
+
const trackType = toTrackType(rawTrackType);
|
|
8633
|
+
if (!trackType) {
|
|
8634
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8635
|
+
}
|
|
8353
8636
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
8354
|
-
|
|
8637
|
+
track.addEventListener('mute', () => {
|
|
8355
8638
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
8639
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8356
8640
|
});
|
|
8357
|
-
|
|
8641
|
+
track.addEventListener('unmute', () => {
|
|
8358
8642
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
8643
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8359
8644
|
});
|
|
8360
|
-
|
|
8645
|
+
track.addEventListener('ended', () => {
|
|
8361
8646
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
8647
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8362
8648
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
8363
8649
|
});
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8650
|
+
if (track.muted) {
|
|
8651
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8367
8652
|
}
|
|
8368
|
-
this.trackIdToTrackType.set(
|
|
8653
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
8369
8654
|
if (!participantToUpdate) {
|
|
8370
8655
|
this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
|
|
8371
8656
|
this.state.registerOrphanedTrack({
|
|
@@ -8391,13 +8676,30 @@ class Subscriber extends BasePeerConnection {
|
|
|
8391
8676
|
});
|
|
8392
8677
|
// now, dispose the previous stream if it exists
|
|
8393
8678
|
if (previousStream) {
|
|
8394
|
-
this.logger.info(`[onTrack]: Cleaning up previous remote ${
|
|
8679
|
+
this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
8395
8680
|
previousStream.getTracks().forEach((t) => {
|
|
8396
8681
|
t.stop();
|
|
8397
8682
|
previousStream.removeTrack(t);
|
|
8398
8683
|
});
|
|
8399
8684
|
}
|
|
8400
8685
|
};
|
|
8686
|
+
this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
|
|
8687
|
+
if (trackType !== TrackType.AUDIO)
|
|
8688
|
+
return;
|
|
8689
|
+
const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8690
|
+
if (!target)
|
|
8691
|
+
return;
|
|
8692
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
8693
|
+
const current = p.interruptedTracks ?? [];
|
|
8694
|
+
const has = current.includes(trackType);
|
|
8695
|
+
if (interrupted === has)
|
|
8696
|
+
return {};
|
|
8697
|
+
const next = interrupted
|
|
8698
|
+
? pushToIfMissing([...current], trackType)
|
|
8699
|
+
: removeFromIfPresent([...current], trackType);
|
|
8700
|
+
return { interruptedTracks: next };
|
|
8701
|
+
});
|
|
8702
|
+
};
|
|
8401
8703
|
this.negotiate = async (subscriberOffer) => {
|
|
8402
8704
|
await this.pc.setRemoteDescription({
|
|
8403
8705
|
type: 'offer',
|
|
@@ -8577,6 +8879,20 @@ class SfuJoinError extends Error {
|
|
|
8577
8879
|
}
|
|
8578
8880
|
}
|
|
8579
8881
|
|
|
8882
|
+
/**
|
|
8883
|
+
* Creates a fresh `joinResponseTask` with a no-op rejection handler attached
|
|
8884
|
+
* to the underlying promise. The handler marks the rejection path as handled
|
|
8885
|
+
* so a teardown-time reject (e.g., from `close()` during disposal) does not
|
|
8886
|
+
* surface as an `UnhandledPromiseRejection`. Explicit awaiters of
|
|
8887
|
+
* `StreamSfuClient.joinTask` still observe the rejection through their own
|
|
8888
|
+
* `then`/`catch` chain. `.catch()` returns a new promise; the original is
|
|
8889
|
+
* unchanged.
|
|
8890
|
+
*/
|
|
8891
|
+
const makeJoinResponseTask = () => {
|
|
8892
|
+
const task = promiseWithResolvers();
|
|
8893
|
+
task.promise.catch(() => { }); // see the comment above
|
|
8894
|
+
return task;
|
|
8895
|
+
};
|
|
8580
8896
|
/**
|
|
8581
8897
|
* The client used for exchanging information with the SFU.
|
|
8582
8898
|
*/
|
|
@@ -8608,9 +8924,10 @@ class StreamSfuClient {
|
|
|
8608
8924
|
this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
|
|
8609
8925
|
/**
|
|
8610
8926
|
* Promise that resolves when the JoinResponse is received.
|
|
8611
|
-
* Rejects after a certain threshold if the response is not received
|
|
8927
|
+
* Rejects after a certain threshold if the response is not received,
|
|
8928
|
+
* or when the SFU client is disposed before a join completes.
|
|
8612
8929
|
*/
|
|
8613
|
-
this.joinResponseTask =
|
|
8930
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8614
8931
|
/**
|
|
8615
8932
|
* A controller to abort the current requests.
|
|
8616
8933
|
*/
|
|
@@ -8680,14 +8997,21 @@ class StreamSfuClient {
|
|
|
8680
8997
|
};
|
|
8681
8998
|
this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
|
|
8682
8999
|
this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
|
|
8683
|
-
|
|
9000
|
+
// Close the WebSocket whether it has fully opened (`OPEN`) or is still
|
|
9001
|
+
// mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
|
|
9002
|
+
// when `close()` is called on a CONNECTING socket. Without this, an
|
|
9003
|
+
// SFU socket that opens just after teardown would dispatch events into
|
|
9004
|
+
// a Call instance that has already moved on.
|
|
9005
|
+
const ws = this.signalWs;
|
|
9006
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
9007
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
8684
9008
|
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
|
|
8685
|
-
|
|
8686
|
-
|
|
9009
|
+
ws.close(code, `js-client: ${reason}`);
|
|
9010
|
+
ws.removeEventListener('close', this.handleWebSocketClose);
|
|
8687
9011
|
}
|
|
8688
|
-
this.dispose();
|
|
9012
|
+
this.dispose(reason);
|
|
8689
9013
|
};
|
|
8690
|
-
this.dispose = () => {
|
|
9014
|
+
this.dispose = (reason) => {
|
|
8691
9015
|
this.logger.debug('Disposing SFU client');
|
|
8692
9016
|
this.unsubscribeIceTrickle();
|
|
8693
9017
|
this.unsubscribeNetworkChanged();
|
|
@@ -8696,6 +9020,13 @@ class StreamSfuClient {
|
|
|
8696
9020
|
clearTimeout(this.migrateAwayTimeout);
|
|
8697
9021
|
this.abortController.abort();
|
|
8698
9022
|
this.migrationTask?.resolve();
|
|
9023
|
+
// Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
|
|
9024
|
+
// any other awaiters (`await this.joinTask`) don't hang indefinitely
|
|
9025
|
+
// when the SFU client is torn down before the SFU sent a JoinResponse.
|
|
9026
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
9027
|
+
!this.joinResponseTask.isRejected()) {
|
|
9028
|
+
this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
|
|
9029
|
+
}
|
|
8699
9030
|
this.iceTrickleBuffer.dispose();
|
|
8700
9031
|
};
|
|
8701
9032
|
this.getTrace = () => {
|
|
@@ -8704,8 +9035,24 @@ class StreamSfuClient {
|
|
|
8704
9035
|
this.leaveAndClose = async (reason) => {
|
|
8705
9036
|
try {
|
|
8706
9037
|
this.isLeaving = true;
|
|
8707
|
-
|
|
8708
|
-
|
|
9038
|
+
// Best-effort: give an in-flight join a short grace period to complete
|
|
9039
|
+
// so we can send a graceful `leaveCallRequest`. Bounded so we never hang
|
|
9040
|
+
// here if the SFU is unresponsive. If the task settles either way during
|
|
9041
|
+
// the wait, the re-check below decides whether to notify.
|
|
9042
|
+
if (!this.joinResponseTask.isResolved() &&
|
|
9043
|
+
!this.joinResponseTask.isRejected()) {
|
|
9044
|
+
await Promise.race([
|
|
9045
|
+
// swallow rejection — we re-check `isResolved()` below to decide
|
|
9046
|
+
this.joinResponseTask.promise.catch(() => { }),
|
|
9047
|
+
sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
|
|
9048
|
+
]);
|
|
9049
|
+
}
|
|
9050
|
+
if (this.joinResponseTask.isResolved()) {
|
|
9051
|
+
await this.notifyLeave(reason);
|
|
9052
|
+
}
|
|
9053
|
+
else {
|
|
9054
|
+
this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
|
|
9055
|
+
}
|
|
8709
9056
|
}
|
|
8710
9057
|
catch (err) {
|
|
8711
9058
|
this.logger.debug('Error notifying SFU about leaving call', err);
|
|
@@ -8774,9 +9121,9 @@ class StreamSfuClient {
|
|
|
8774
9121
|
this.joinResponseTask.isRejected()) {
|
|
8775
9122
|
// we need to lock the RPC requests until we receive a JoinResponse.
|
|
8776
9123
|
// that's why we have this primitive lock mechanism.
|
|
8777
|
-
// the client starts with already initialized joinResponseTask,
|
|
9124
|
+
// the client starts with an already initialized joinResponseTask,
|
|
8778
9125
|
// and this code creates a new one for the next join request.
|
|
8779
|
-
this.joinResponseTask =
|
|
9126
|
+
this.joinResponseTask = makeJoinResponseTask();
|
|
8780
9127
|
}
|
|
8781
9128
|
// capture a reference to the current joinResponseTask as it might
|
|
8782
9129
|
// be replaced with a new one in case a second join request is made
|
|
@@ -8949,6 +9296,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
|
|
|
8949
9296
|
* The close code used when the client fails to join the call (on the SFU).
|
|
8950
9297
|
*/
|
|
8951
9298
|
StreamSfuClient.JOIN_FAILED = 4101;
|
|
9299
|
+
/**
|
|
9300
|
+
* Best-effort grace period in `leaveAndClose` for an in-flight join to
|
|
9301
|
+
* complete before we give up and close without sending `leaveCallRequest`.
|
|
9302
|
+
* Bounded so a stuck join can never hang the leave path.
|
|
9303
|
+
*/
|
|
9304
|
+
StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
|
|
8952
9305
|
|
|
8953
9306
|
/**
|
|
8954
9307
|
* Event handler that watched the delivery of `call.accepted`.
|
|
@@ -9069,36 +9422,6 @@ const watchCallGrantsUpdated = (state) => {
|
|
|
9069
9422
|
};
|
|
9070
9423
|
};
|
|
9071
9424
|
|
|
9072
|
-
/**
|
|
9073
|
-
* Adds unique values to an array.
|
|
9074
|
-
*
|
|
9075
|
-
* @param arr the array to add to.
|
|
9076
|
-
* @param values the values to add.
|
|
9077
|
-
*/
|
|
9078
|
-
const pushToIfMissing = (arr, ...values) => {
|
|
9079
|
-
for (const v of values) {
|
|
9080
|
-
if (!arr.includes(v)) {
|
|
9081
|
-
arr.push(v);
|
|
9082
|
-
}
|
|
9083
|
-
}
|
|
9084
|
-
return arr;
|
|
9085
|
-
};
|
|
9086
|
-
/**
|
|
9087
|
-
* Removes values from an array if they are present.
|
|
9088
|
-
*
|
|
9089
|
-
* @param arr the array to remove from.
|
|
9090
|
-
* @param values the values to remove.
|
|
9091
|
-
*/
|
|
9092
|
-
const removeFromIfPresent = (arr, ...values) => {
|
|
9093
|
-
for (const v of values) {
|
|
9094
|
-
const index = arr.indexOf(v);
|
|
9095
|
-
if (index !== -1) {
|
|
9096
|
-
arr.splice(index, 1);
|
|
9097
|
-
}
|
|
9098
|
-
}
|
|
9099
|
-
return arr;
|
|
9100
|
-
};
|
|
9101
|
-
|
|
9102
9425
|
const watchConnectionQualityChanged = (dispatcher, state) => {
|
|
9103
9426
|
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
9104
9427
|
const { connectionQualityUpdates } = e;
|
|
@@ -9431,140 +9754,54 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
9431
9754
|
};
|
|
9432
9755
|
};
|
|
9433
9756
|
|
|
9434
|
-
const
|
|
9435
|
-
|
|
9436
|
-
|
|
9757
|
+
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9758
|
+
/**
|
|
9759
|
+
* Tracks audio element bindings and periodically warns about
|
|
9760
|
+
* remote participants whose audio streams have no bound element.
|
|
9761
|
+
*/
|
|
9762
|
+
class AudioBindingsWatchdog {
|
|
9763
|
+
constructor(state, tracer) {
|
|
9764
|
+
this.bindings = new Map();
|
|
9765
|
+
this.enabled = true;
|
|
9766
|
+
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9437
9767
|
/**
|
|
9438
|
-
*
|
|
9768
|
+
* Registers an audio element binding for the given session and track type.
|
|
9769
|
+
* Warns if a different element is already bound to the same key.
|
|
9439
9770
|
*/
|
|
9440
|
-
this.
|
|
9771
|
+
this.register = (element, sessionId, trackType) => {
|
|
9772
|
+
const key = toBindingKey(sessionId, trackType);
|
|
9773
|
+
const existing = this.bindings.get(key);
|
|
9774
|
+
if (existing && existing !== element) {
|
|
9775
|
+
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9776
|
+
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9777
|
+
}
|
|
9778
|
+
this.bindings.set(key, element);
|
|
9779
|
+
};
|
|
9441
9780
|
/**
|
|
9442
|
-
*
|
|
9781
|
+
* Removes the audio element binding for the given session and track type.
|
|
9443
9782
|
*/
|
|
9444
|
-
this.
|
|
9445
|
-
|
|
9446
|
-
|
|
9783
|
+
this.unregister = (sessionId, trackType) => {
|
|
9784
|
+
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9785
|
+
};
|
|
9447
9786
|
/**
|
|
9448
|
-
*
|
|
9787
|
+
* Enables or disables the watchdog.
|
|
9788
|
+
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9449
9789
|
*/
|
|
9450
|
-
this.
|
|
9790
|
+
this.setEnabled = (enabled) => {
|
|
9791
|
+
this.enabled = enabled;
|
|
9792
|
+
if (enabled) {
|
|
9793
|
+
this.start();
|
|
9794
|
+
}
|
|
9795
|
+
else {
|
|
9796
|
+
this.stop();
|
|
9797
|
+
}
|
|
9798
|
+
};
|
|
9451
9799
|
/**
|
|
9452
|
-
*
|
|
9453
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9454
|
-
*
|
|
9455
|
-
* @param viewportElement
|
|
9456
|
-
* @param options
|
|
9457
|
-
* @returns Unobserve
|
|
9458
|
-
*/
|
|
9459
|
-
this.setViewport = (viewportElement, options) => {
|
|
9460
|
-
const cleanup = () => {
|
|
9461
|
-
this.observer?.disconnect();
|
|
9462
|
-
this.observer = null;
|
|
9463
|
-
this.elementHandlerMap.clear();
|
|
9464
|
-
};
|
|
9465
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
9466
|
-
entries.forEach((entry) => {
|
|
9467
|
-
const handler = this.elementHandlerMap.get(entry.target);
|
|
9468
|
-
handler?.(entry);
|
|
9469
|
-
});
|
|
9470
|
-
}, {
|
|
9471
|
-
root: viewportElement,
|
|
9472
|
-
...options,
|
|
9473
|
-
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
9474
|
-
});
|
|
9475
|
-
if (this.queueSet.size) {
|
|
9476
|
-
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
9477
|
-
// check if element which requested observation is
|
|
9478
|
-
// a child of a viewport element, skip if isn't
|
|
9479
|
-
if (!viewportElement.contains(queueElement))
|
|
9480
|
-
return;
|
|
9481
|
-
this.observer.observe(queueElement);
|
|
9482
|
-
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
9483
|
-
});
|
|
9484
|
-
this.queueSet.clear();
|
|
9485
|
-
}
|
|
9486
|
-
return cleanup;
|
|
9487
|
-
};
|
|
9488
|
-
/**
|
|
9489
|
-
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
9490
|
-
* detects a possible change in element's visibility within specified viewport, returns
|
|
9491
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9492
|
-
*
|
|
9493
|
-
* @param element
|
|
9494
|
-
* @param handler
|
|
9495
|
-
* @returns Unobserve
|
|
9496
|
-
*/
|
|
9497
|
-
this.observe = (element, handler) => {
|
|
9498
|
-
const queueItem = [element, handler];
|
|
9499
|
-
const cleanup = () => {
|
|
9500
|
-
this.elementHandlerMap.delete(element);
|
|
9501
|
-
this.observer?.unobserve(element);
|
|
9502
|
-
this.queueSet.delete(queueItem);
|
|
9503
|
-
};
|
|
9504
|
-
if (this.elementHandlerMap.has(element))
|
|
9505
|
-
return cleanup;
|
|
9506
|
-
if (!this.observer) {
|
|
9507
|
-
this.queueSet.add(queueItem);
|
|
9508
|
-
return cleanup;
|
|
9509
|
-
}
|
|
9510
|
-
if (this.observer.root.contains(element)) {
|
|
9511
|
-
this.elementHandlerMap.set(element, handler);
|
|
9512
|
-
this.observer.observe(element);
|
|
9513
|
-
}
|
|
9514
|
-
return cleanup;
|
|
9515
|
-
};
|
|
9516
|
-
}
|
|
9517
|
-
}
|
|
9518
|
-
|
|
9519
|
-
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9520
|
-
/**
|
|
9521
|
-
* Tracks audio element bindings and periodically warns about
|
|
9522
|
-
* remote participants whose audio streams have no bound element.
|
|
9523
|
-
*/
|
|
9524
|
-
class AudioBindingsWatchdog {
|
|
9525
|
-
constructor(state, tracer) {
|
|
9526
|
-
this.state = state;
|
|
9527
|
-
this.tracer = tracer;
|
|
9528
|
-
this.bindings = new Map();
|
|
9529
|
-
this.enabled = true;
|
|
9530
|
-
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9531
|
-
/**
|
|
9532
|
-
* Registers an audio element binding for the given session and track type.
|
|
9533
|
-
* Warns if a different element is already bound to the same key.
|
|
9534
|
-
*/
|
|
9535
|
-
this.register = (audioElement, sessionId, trackType) => {
|
|
9536
|
-
const key = toBindingKey(sessionId, trackType);
|
|
9537
|
-
const existing = this.bindings.get(key);
|
|
9538
|
-
if (existing && existing !== audioElement) {
|
|
9539
|
-
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9540
|
-
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9541
|
-
}
|
|
9542
|
-
this.bindings.set(key, audioElement);
|
|
9543
|
-
};
|
|
9544
|
-
/**
|
|
9545
|
-
* Removes the audio element binding for the given session and track type.
|
|
9546
|
-
*/
|
|
9547
|
-
this.unregister = (sessionId, trackType) => {
|
|
9548
|
-
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9549
|
-
};
|
|
9550
|
-
/**
|
|
9551
|
-
* Enables or disables the watchdog.
|
|
9552
|
-
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9553
|
-
*/
|
|
9554
|
-
this.setEnabled = (enabled) => {
|
|
9555
|
-
this.enabled = enabled;
|
|
9556
|
-
if (enabled) {
|
|
9557
|
-
this.start();
|
|
9558
|
-
}
|
|
9559
|
-
else {
|
|
9560
|
-
this.stop();
|
|
9561
|
-
}
|
|
9562
|
-
};
|
|
9563
|
-
/**
|
|
9564
|
-
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9800
|
+
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9565
9801
|
*/
|
|
9566
9802
|
this.dispose = () => {
|
|
9567
9803
|
this.stop();
|
|
9804
|
+
this.bindings.clear();
|
|
9568
9805
|
this.unsubscribeCallingState();
|
|
9569
9806
|
};
|
|
9570
9807
|
this.start = () => {
|
|
@@ -9596,6 +9833,8 @@ class AudioBindingsWatchdog {
|
|
|
9596
9833
|
this.stop = () => {
|
|
9597
9834
|
clearInterval(this.watchdogInterval);
|
|
9598
9835
|
};
|
|
9836
|
+
this.tracer = tracer;
|
|
9837
|
+
this.state = state;
|
|
9599
9838
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
9600
9839
|
if (!this.enabled)
|
|
9601
9840
|
return;
|
|
@@ -9609,61 +9848,97 @@ class AudioBindingsWatchdog {
|
|
|
9609
9848
|
}
|
|
9610
9849
|
}
|
|
9611
9850
|
|
|
9612
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
9613
|
-
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
9614
|
-
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
9615
|
-
};
|
|
9616
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9617
9851
|
/**
|
|
9618
|
-
*
|
|
9619
|
-
*
|
|
9620
|
-
* - binding video elements to session ids
|
|
9621
|
-
* - binding audio elements to session ids
|
|
9622
|
-
* - tracking element visibility
|
|
9623
|
-
* - updating subscriptions based on viewport visibility
|
|
9624
|
-
* - updating subscriptions based on video element dimensions
|
|
9625
|
-
* - updating subscriptions based on published tracks
|
|
9852
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9626
9853
|
*/
|
|
9627
|
-
class
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
constructor(callState, speaker, tracer) {
|
|
9854
|
+
class BlockedAudioTracker {
|
|
9855
|
+
constructor(tracer) {
|
|
9856
|
+
this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
9857
|
+
this.blockedElementsSubject = new rxjs.BehaviorSubject(new Set());
|
|
9632
9858
|
/**
|
|
9633
|
-
*
|
|
9859
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
9860
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
9861
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
9634
9862
|
*/
|
|
9635
|
-
this.
|
|
9636
|
-
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
9637
|
-
this.useWebAudio = false;
|
|
9638
|
-
this.pendingSubscriptionsUpdate = null;
|
|
9863
|
+
this.autoplayBlocked$ = this.blockedElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
|
|
9639
9864
|
/**
|
|
9640
|
-
*
|
|
9641
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
9865
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
9642
9866
|
*/
|
|
9643
|
-
this.
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
this.addBlockedAudioElement = (audioElement) => {
|
|
9651
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
9652
|
-
const next = new Set(elements);
|
|
9653
|
-
next.add(audioElement);
|
|
9654
|
-
return next;
|
|
9867
|
+
this.markBlocked = (audioElement, blocked) => {
|
|
9868
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
9869
|
+
if (blocked)
|
|
9870
|
+
elements.add(audioElement);
|
|
9871
|
+
else
|
|
9872
|
+
elements.delete(audioElement);
|
|
9873
|
+
return elements;
|
|
9655
9874
|
});
|
|
9656
9875
|
};
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9876
|
+
/**
|
|
9877
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
9878
|
+
* by the browser's autoplay policy.
|
|
9879
|
+
*/
|
|
9880
|
+
this.isBlocked = (audioElement) => {
|
|
9881
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
9882
|
+
};
|
|
9883
|
+
/**
|
|
9884
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
9885
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
9886
|
+
*/
|
|
9887
|
+
this.resumeAudio = async () => {
|
|
9888
|
+
this.tracer.trace('resumeAudio', null);
|
|
9889
|
+
await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
|
|
9890
|
+
await Promise.all(Array.from(elements, async (element) => {
|
|
9891
|
+
try {
|
|
9892
|
+
if (element.srcObject)
|
|
9893
|
+
await timeboxed([element.play()], 2000);
|
|
9894
|
+
elements.delete(element);
|
|
9895
|
+
}
|
|
9896
|
+
catch (err) {
|
|
9897
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
9898
|
+
}
|
|
9899
|
+
}));
|
|
9900
|
+
return elements;
|
|
9662
9901
|
});
|
|
9663
9902
|
};
|
|
9664
|
-
this.
|
|
9665
|
-
|
|
9666
|
-
|
|
9903
|
+
this.tracer = tracer;
|
|
9904
|
+
}
|
|
9905
|
+
}
|
|
9906
|
+
|
|
9907
|
+
/** Symbol key for the "applies to all participants" override slot. */
|
|
9908
|
+
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9909
|
+
/**
|
|
9910
|
+
* Owns the SFU-side video-subscription machinery for a `Call`:
|
|
9911
|
+
*
|
|
9912
|
+
* - Holds the per-session / global override state in a
|
|
9913
|
+
* `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
|
|
9914
|
+
* - Derives the SFU subscription list from `CallState` participants +
|
|
9915
|
+
* current overrides via the `subscriptions` getter.
|
|
9916
|
+
* - Debounces and pushes the list to the SFU through
|
|
9917
|
+
* `sfuClient.updateSubscriptions`.
|
|
9918
|
+
* - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
|
|
9919
|
+
* the override state for React hooks.
|
|
9920
|
+
*
|
|
9921
|
+
* Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
|
|
9922
|
+
* `DynascaleManager.bindVideoElement` triggers `apply()` on every
|
|
9923
|
+
* dimension / visibility change.
|
|
9924
|
+
*/
|
|
9925
|
+
class TrackSubscriptionManager {
|
|
9926
|
+
/**
|
|
9927
|
+
* Constructs new TrackSubscriptionManager instance.
|
|
9928
|
+
*
|
|
9929
|
+
* @param callState the call state.
|
|
9930
|
+
* @param tracer the tracer to use.
|
|
9931
|
+
*/
|
|
9932
|
+
constructor(callState, tracer) {
|
|
9933
|
+
this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
|
|
9934
|
+
this.pendingUpdate = null;
|
|
9935
|
+
this.overridesSubject = new rxjs.BehaviorSubject({});
|
|
9936
|
+
this.overrides$ = this.overridesSubject.asObservable();
|
|
9937
|
+
/**
|
|
9938
|
+
* Consumer-friendly projection of the override state. Used by the
|
|
9939
|
+
* `useIncomingVideoSettings()` React hook.
|
|
9940
|
+
*/
|
|
9941
|
+
this.incomingVideoSettings$ = this.overrides$.pipe(rxjs.map((overrides) => {
|
|
9667
9942
|
const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
|
|
9668
9943
|
return {
|
|
9669
9944
|
enabled: globalSettings?.enabled !== false,
|
|
@@ -9685,106 +9960,255 @@ class DynascaleManager {
|
|
|
9685
9960
|
};
|
|
9686
9961
|
}), rxjs.shareReplay(1));
|
|
9687
9962
|
/**
|
|
9688
|
-
*
|
|
9963
|
+
* Sets the SFU client used by `apply()` to push subscription updates.
|
|
9964
|
+
* Called by the owner on call join; cleared on leave.
|
|
9689
9965
|
*/
|
|
9690
|
-
this.
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
if (
|
|
9698
|
-
|
|
9699
|
-
|
|
9700
|
-
this.audioContext = undefined;
|
|
9966
|
+
this.setSfuClient = (sfuClient) => {
|
|
9967
|
+
this.sfuClient = sfuClient;
|
|
9968
|
+
};
|
|
9969
|
+
/**
|
|
9970
|
+
* Cancels any pending debounced subscription push. Idempotent.
|
|
9971
|
+
*/
|
|
9972
|
+
this.dispose = () => {
|
|
9973
|
+
if (this.pendingUpdate) {
|
|
9974
|
+
clearTimeout(this.pendingUpdate);
|
|
9975
|
+
this.pendingUpdate = null;
|
|
9701
9976
|
}
|
|
9702
9977
|
};
|
|
9703
|
-
|
|
9704
|
-
|
|
9705
|
-
|
|
9706
|
-
|
|
9707
|
-
|
|
9978
|
+
/**
|
|
9979
|
+
* Sets video-subscription overrides. Called by
|
|
9980
|
+
* `Call.setIncomingVideoEnabled` and
|
|
9981
|
+
* `Call.setPreferredIncomingVideoResolution`.
|
|
9982
|
+
*
|
|
9983
|
+
* - `sessionIds` omitted → applies `override` globally (or clears the
|
|
9984
|
+
* global override if `override` is `undefined`).
|
|
9985
|
+
* - `sessionIds` provided → applies `override` to each listed session.
|
|
9986
|
+
*/
|
|
9987
|
+
this.setOverrides = (override, sessionIds) => {
|
|
9988
|
+
this.tracer.trace('setOverrides', [override, sessionIds]);
|
|
9708
9989
|
if (!sessionIds) {
|
|
9709
|
-
return setCurrentValue(this.
|
|
9990
|
+
return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
|
|
9710
9991
|
}
|
|
9711
|
-
return setCurrentValue(this.
|
|
9992
|
+
return setCurrentValue(this.overridesSubject, (overrides) => ({
|
|
9712
9993
|
...overrides,
|
|
9713
9994
|
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
9714
9995
|
}));
|
|
9715
9996
|
};
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9997
|
+
/**
|
|
9998
|
+
* Pushes `subscriptions` to the SFU. Debounced by `debounceType`
|
|
9999
|
+
* (SLOW by default). Multiple rapid calls coalesce into one RPC.
|
|
10000
|
+
* Passing `0` fires synchronously.
|
|
10001
|
+
*/
|
|
10002
|
+
this.apply = (debounceType = exports.DebounceType.SLOW) => {
|
|
10003
|
+
if (this.pendingUpdate) {
|
|
10004
|
+
clearTimeout(this.pendingUpdate);
|
|
9719
10005
|
}
|
|
9720
10006
|
const updateSubscriptions = () => {
|
|
9721
|
-
this.
|
|
10007
|
+
this.pendingUpdate = null;
|
|
9722
10008
|
this.sfuClient
|
|
9723
|
-
?.updateSubscriptions(this.
|
|
10009
|
+
?.updateSubscriptions(this.subscriptions)
|
|
9724
10010
|
.catch((err) => {
|
|
9725
10011
|
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
9726
10012
|
});
|
|
9727
10013
|
};
|
|
9728
10014
|
if (debounceType) {
|
|
9729
|
-
this.
|
|
10015
|
+
this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
|
|
9730
10016
|
}
|
|
9731
10017
|
else {
|
|
9732
10018
|
updateSubscriptions();
|
|
9733
10019
|
}
|
|
9734
10020
|
};
|
|
9735
|
-
|
|
9736
|
-
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
9743
|
-
|
|
9744
|
-
|
|
9745
|
-
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
9760
|
-
|
|
10021
|
+
this.tracer = tracer;
|
|
10022
|
+
this.callState = callState;
|
|
10023
|
+
}
|
|
10024
|
+
/**
|
|
10025
|
+
* The current SFU subscription list, computed from `CallState`
|
|
10026
|
+
* participants and the override state. Used by:
|
|
10027
|
+
*
|
|
10028
|
+
* - `apply()` to push to the SFU each time the set changes.
|
|
10029
|
+
* - `Call.getReconnectDetails` to include the subscription list in
|
|
10030
|
+
* the reconnect payload.
|
|
10031
|
+
*/
|
|
10032
|
+
get subscriptions() {
|
|
10033
|
+
const subscriptions = [];
|
|
10034
|
+
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
10035
|
+
// and avoid stale data caused by shareReplay with no active subscribers
|
|
10036
|
+
const participants = this.callState.getParticipantsSnapshot();
|
|
10037
|
+
const overrides = this.overridesSubject.getValue();
|
|
10038
|
+
for (const p of participants) {
|
|
10039
|
+
if (p.isLocalParticipant)
|
|
10040
|
+
continue;
|
|
10041
|
+
// NOTE: audio tracks don't have to be requested explicitly
|
|
10042
|
+
// as the SFU will implicitly subscribe us to all of them,
|
|
10043
|
+
// once they become available.
|
|
10044
|
+
if (p.videoDimension && hasVideo(p)) {
|
|
10045
|
+
const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
|
|
10046
|
+
if (override?.enabled !== false) {
|
|
10047
|
+
subscriptions.push({
|
|
10048
|
+
userId: p.userId,
|
|
10049
|
+
sessionId: p.sessionId,
|
|
10050
|
+
trackType: TrackType.VIDEO,
|
|
10051
|
+
dimension: override?.dimension ?? p.videoDimension,
|
|
10052
|
+
});
|
|
10053
|
+
}
|
|
10054
|
+
}
|
|
10055
|
+
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
10056
|
+
subscriptions.push({
|
|
10057
|
+
userId: p.userId,
|
|
10058
|
+
sessionId: p.sessionId,
|
|
10059
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10060
|
+
dimension: p.screenShareDimension,
|
|
10061
|
+
});
|
|
10062
|
+
}
|
|
10063
|
+
if (hasScreenShareAudio(p)) {
|
|
10064
|
+
subscriptions.push({
|
|
10065
|
+
userId: p.userId,
|
|
10066
|
+
sessionId: p.sessionId,
|
|
10067
|
+
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
9761
10068
|
});
|
|
10069
|
+
}
|
|
10070
|
+
}
|
|
10071
|
+
return subscriptions;
|
|
10072
|
+
}
|
|
10073
|
+
get overrides() {
|
|
10074
|
+
return getCurrentValue(this.overrides$);
|
|
10075
|
+
}
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
/**
|
|
10079
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
10080
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
10081
|
+
*/
|
|
10082
|
+
class MediaPlaybackWatchdog {
|
|
10083
|
+
constructor(opts) {
|
|
10084
|
+
this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
10085
|
+
this.controller = new AbortController();
|
|
10086
|
+
this.attempt = 0;
|
|
10087
|
+
this.disposed = false;
|
|
10088
|
+
this.attach = () => {
|
|
10089
|
+
if (this.disposed)
|
|
10090
|
+
return;
|
|
10091
|
+
const { signal } = this.controller;
|
|
10092
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
10093
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
10094
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
10095
|
+
};
|
|
10096
|
+
this.dispose = () => {
|
|
10097
|
+
if (this.disposed)
|
|
10098
|
+
return;
|
|
10099
|
+
this.disposed = true;
|
|
10100
|
+
this.controller.abort();
|
|
10101
|
+
if (this.pendingTimer)
|
|
10102
|
+
clearTimeout(this.pendingTimer);
|
|
10103
|
+
this.pendingTimer = undefined;
|
|
10104
|
+
};
|
|
10105
|
+
this.onPlaying = () => {
|
|
10106
|
+
if (this.attempt > 0) {
|
|
10107
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
10108
|
+
kind: this.kind,
|
|
10109
|
+
attempts: this.attempt,
|
|
10110
|
+
});
|
|
10111
|
+
}
|
|
10112
|
+
this.attempt = 0;
|
|
10113
|
+
if (this.pendingTimer)
|
|
10114
|
+
clearTimeout(this.pendingTimer);
|
|
10115
|
+
this.pendingTimer = undefined;
|
|
10116
|
+
};
|
|
10117
|
+
this.onPauseOrSuspend = (event) => {
|
|
10118
|
+
if (this.disposed)
|
|
10119
|
+
return;
|
|
10120
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
10121
|
+
kind: this.kind,
|
|
10122
|
+
reason: event.type,
|
|
9762
10123
|
});
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9773
|
-
viewportVisibilityState: {
|
|
9774
|
-
...previousVisibilityState,
|
|
9775
|
-
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
9776
|
-
},
|
|
9777
|
-
};
|
|
10124
|
+
this.scheduleRecovery();
|
|
10125
|
+
};
|
|
10126
|
+
this.scheduleRecovery = () => {
|
|
10127
|
+
if (this.disposed || this.pendingTimer)
|
|
10128
|
+
return;
|
|
10129
|
+
const skipReason = this.computeSkipReason();
|
|
10130
|
+
if (skipReason) {
|
|
10131
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
10132
|
+
kind: this.kind,
|
|
10133
|
+
reason: skipReason,
|
|
9778
10134
|
});
|
|
9779
|
-
|
|
10135
|
+
return;
|
|
10136
|
+
}
|
|
10137
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
10138
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
10139
|
+
};
|
|
10140
|
+
this.computeSkipReason = () => {
|
|
10141
|
+
if (this.disposed)
|
|
10142
|
+
return 'disposed';
|
|
10143
|
+
if (!this.element.srcObject)
|
|
10144
|
+
return 'noSrc';
|
|
10145
|
+
if (this.element.ended)
|
|
10146
|
+
return 'ended';
|
|
10147
|
+
if (this.isBlocked())
|
|
10148
|
+
return 'blocked';
|
|
10149
|
+
const HAVE_CURRENT_DATA = 2;
|
|
10150
|
+
if (this.element.readyState < HAVE_CURRENT_DATA)
|
|
10151
|
+
return 'notReady';
|
|
10152
|
+
if (!this.element.paused)
|
|
10153
|
+
return 'notPaused';
|
|
10154
|
+
};
|
|
10155
|
+
this.attemptPlay = async () => {
|
|
10156
|
+
this.pendingTimer = undefined;
|
|
10157
|
+
if (this.disposed)
|
|
10158
|
+
return;
|
|
10159
|
+
this.attempt += 1;
|
|
10160
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
10161
|
+
kind: this.kind,
|
|
10162
|
+
attempt: this.attempt,
|
|
10163
|
+
});
|
|
10164
|
+
try {
|
|
10165
|
+
await timeboxed([this.element.play()], 2000);
|
|
10166
|
+
}
|
|
10167
|
+
catch (err) {
|
|
10168
|
+
if (this.disposed)
|
|
10169
|
+
return;
|
|
10170
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
10171
|
+
if (this.attempt >= 10) {
|
|
10172
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
10173
|
+
kind: this.kind,
|
|
10174
|
+
attempts: this.attempt,
|
|
10175
|
+
});
|
|
10176
|
+
return;
|
|
10177
|
+
}
|
|
10178
|
+
this.scheduleRecovery();
|
|
10179
|
+
}
|
|
9780
10180
|
};
|
|
10181
|
+
this.element = opts.element;
|
|
10182
|
+
this.kind = opts.kind;
|
|
10183
|
+
this.tracer = opts.tracer;
|
|
10184
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
10185
|
+
this.attach();
|
|
10186
|
+
}
|
|
10187
|
+
}
|
|
10188
|
+
|
|
10189
|
+
/**
|
|
10190
|
+
* A manager class that handles dynascale related tasks like:
|
|
10191
|
+
*
|
|
10192
|
+
* - binding video elements to session ids
|
|
10193
|
+
* - binding audio elements to session ids
|
|
10194
|
+
*/
|
|
10195
|
+
class DynascaleManager {
|
|
10196
|
+
/**
|
|
10197
|
+
* Creates a new DynascaleManager instance.
|
|
10198
|
+
*/
|
|
10199
|
+
constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
|
|
10200
|
+
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
10201
|
+
this.useWebAudio = false;
|
|
9781
10202
|
/**
|
|
9782
|
-
*
|
|
9783
|
-
*
|
|
9784
|
-
* @param element the viewport element.
|
|
10203
|
+
* Closes the audio context if it was created.
|
|
9785
10204
|
*/
|
|
9786
|
-
this.
|
|
9787
|
-
|
|
10205
|
+
this.dispose = async () => {
|
|
10206
|
+
const context = this.audioContext;
|
|
10207
|
+
if (context && context.state !== 'closed') {
|
|
10208
|
+
document.removeEventListener('click', this.resumeAudioContext);
|
|
10209
|
+
await context.close();
|
|
10210
|
+
this.audioContext = undefined;
|
|
10211
|
+
}
|
|
9788
10212
|
};
|
|
9789
10213
|
/**
|
|
9790
10214
|
* Sets whether to use WebAudio API for audio playback.
|
|
@@ -9829,7 +10253,7 @@ class DynascaleManager {
|
|
|
9829
10253
|
this.callState.updateParticipantTracks(trackType, {
|
|
9830
10254
|
[sessionId]: { dimension },
|
|
9831
10255
|
});
|
|
9832
|
-
this.
|
|
10256
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
9833
10257
|
};
|
|
9834
10258
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((participant) => !!participant), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
9835
10259
|
/**
|
|
@@ -9918,6 +10342,11 @@ class DynascaleManager {
|
|
|
9918
10342
|
// without prior user interaction:
|
|
9919
10343
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
9920
10344
|
videoElement.muted = true;
|
|
10345
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
10346
|
+
element: videoElement,
|
|
10347
|
+
kind: 'video',
|
|
10348
|
+
tracer: this.tracer,
|
|
10349
|
+
});
|
|
9921
10350
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
9922
10351
|
const streamSubscription = participant$
|
|
9923
10352
|
.pipe(rxjs.distinctUntilKeyChanged(trackKey))
|
|
@@ -9927,14 +10356,14 @@ class DynascaleManager {
|
|
|
9927
10356
|
return;
|
|
9928
10357
|
videoElement.srcObject = source ?? null;
|
|
9929
10358
|
if (isSafari() || isFirefox()) {
|
|
9930
|
-
setTimeout(() => {
|
|
10359
|
+
setTimeout(async () => {
|
|
9931
10360
|
videoElement.srcObject = source ?? null;
|
|
9932
|
-
|
|
10361
|
+
try {
|
|
10362
|
+
await timeboxed([videoElement.play()], 2000);
|
|
10363
|
+
}
|
|
10364
|
+
catch (e) {
|
|
9933
10365
|
this.logger.warn(`Failed to play stream`, e);
|
|
9934
|
-
}
|
|
9935
|
-
// we add extra delay until we attempt to force-play
|
|
9936
|
-
// the participant's media stream in Firefox and Safari,
|
|
9937
|
-
// as they seem to have some timing issues
|
|
10366
|
+
}
|
|
9938
10367
|
}, 25);
|
|
9939
10368
|
}
|
|
9940
10369
|
});
|
|
@@ -9944,6 +10373,7 @@ class DynascaleManager {
|
|
|
9944
10373
|
publishedTracksSubscription?.unsubscribe();
|
|
9945
10374
|
streamSubscription.unsubscribe();
|
|
9946
10375
|
resizeObserver?.disconnect();
|
|
10376
|
+
playbackWatchdog.dispose();
|
|
9947
10377
|
};
|
|
9948
10378
|
};
|
|
9949
10379
|
/**
|
|
@@ -9961,7 +10391,6 @@ class DynascaleManager {
|
|
|
9961
10391
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
9962
10392
|
if (!participant || participant.isLocalParticipant)
|
|
9963
10393
|
return;
|
|
9964
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
9965
10394
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
9966
10395
|
const updateSinkId = (deviceId, audioContext) => {
|
|
9967
10396
|
if (!deviceId)
|
|
@@ -9980,6 +10409,7 @@ class DynascaleManager {
|
|
|
9980
10409
|
};
|
|
9981
10410
|
let sourceNode = undefined;
|
|
9982
10411
|
let gainNode = undefined;
|
|
10412
|
+
let audioWatchdog = undefined;
|
|
9983
10413
|
const isAudioTrack = trackType === 'audioTrack';
|
|
9984
10414
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
9985
10415
|
const updateMediaStreamSubscription = participant$
|
|
@@ -9990,8 +10420,10 @@ class DynascaleManager {
|
|
|
9990
10420
|
return;
|
|
9991
10421
|
setTimeout(() => {
|
|
9992
10422
|
audioElement.srcObject = source ?? null;
|
|
10423
|
+
audioWatchdog?.dispose();
|
|
10424
|
+
audioWatchdog = undefined;
|
|
9993
10425
|
if (!source) {
|
|
9994
|
-
this.
|
|
10426
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
9995
10427
|
return;
|
|
9996
10428
|
}
|
|
9997
10429
|
// Safari has a special quirk that prevents playing audio until the user
|
|
@@ -10019,10 +10451,16 @@ class DynascaleManager {
|
|
|
10019
10451
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
10020
10452
|
if (e.name === 'NotAllowedError') {
|
|
10021
10453
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
10022
|
-
this.
|
|
10454
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
10023
10455
|
}
|
|
10024
10456
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
10025
10457
|
});
|
|
10458
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
10459
|
+
element: audioElement,
|
|
10460
|
+
kind: 'audio',
|
|
10461
|
+
tracer: this.tracer,
|
|
10462
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
10463
|
+
});
|
|
10026
10464
|
}
|
|
10027
10465
|
const { selectedDevice } = this.speaker.state;
|
|
10028
10466
|
if (selectedDevice)
|
|
@@ -10046,38 +10484,17 @@ class DynascaleManager {
|
|
|
10046
10484
|
});
|
|
10047
10485
|
audioElement.autoplay = true;
|
|
10048
10486
|
return () => {
|
|
10049
|
-
this.
|
|
10050
|
-
this.removeBlockedAudioElement(audioElement);
|
|
10487
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10051
10488
|
sinkIdSubscription?.unsubscribe();
|
|
10052
10489
|
volumeSubscription.unsubscribe();
|
|
10053
10490
|
updateMediaStreamSubscription.unsubscribe();
|
|
10054
10491
|
audioElement.srcObject = null;
|
|
10055
10492
|
sourceNode?.disconnect();
|
|
10056
10493
|
gainNode?.disconnect();
|
|
10494
|
+
audioWatchdog?.dispose();
|
|
10495
|
+
audioWatchdog = undefined;
|
|
10057
10496
|
};
|
|
10058
10497
|
};
|
|
10059
|
-
/**
|
|
10060
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10061
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
10062
|
-
*
|
|
10063
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
10064
|
-
*/
|
|
10065
|
-
this.resumeAudio = async () => {
|
|
10066
|
-
this.tracer.trace('resumeAudio', null);
|
|
10067
|
-
const blocked = new Set();
|
|
10068
|
-
await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
|
|
10069
|
-
try {
|
|
10070
|
-
if (el.srcObject) {
|
|
10071
|
-
await el.play();
|
|
10072
|
-
}
|
|
10073
|
-
}
|
|
10074
|
-
catch {
|
|
10075
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
10076
|
-
blocked.add(el);
|
|
10077
|
-
}
|
|
10078
|
-
}));
|
|
10079
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
10080
|
-
};
|
|
10081
10498
|
this.getOrCreateAudioContext = () => {
|
|
10082
10499
|
if (!this.useWebAudio)
|
|
10083
10500
|
return;
|
|
@@ -10130,57 +10547,124 @@ class DynascaleManager {
|
|
|
10130
10547
|
this.callState = callState;
|
|
10131
10548
|
this.speaker = speaker;
|
|
10132
10549
|
this.tracer = tracer;
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
}
|
|
10136
|
-
}
|
|
10137
|
-
setSfuClient(sfuClient) {
|
|
10138
|
-
this.sfuClient = sfuClient;
|
|
10550
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
10551
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
10139
10552
|
}
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
}
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
trackType: TrackType.SCREEN_SHARE,
|
|
10169
|
-
dimension: p.screenShareDimension,
|
|
10553
|
+
}
|
|
10554
|
+
|
|
10555
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
10556
|
+
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
10557
|
+
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
10558
|
+
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
10559
|
+
};
|
|
10560
|
+
class ViewportTracker {
|
|
10561
|
+
constructor(callState) {
|
|
10562
|
+
this.elementHandlerMap = new Map();
|
|
10563
|
+
this.observer = null;
|
|
10564
|
+
// in React children render before viewport is set, add
|
|
10565
|
+
// them to the queue and observe them once the observer is ready
|
|
10566
|
+
this.queueSet = new Set();
|
|
10567
|
+
/**
|
|
10568
|
+
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
10569
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10570
|
+
*/
|
|
10571
|
+
this.setViewport = (viewportElement, options) => {
|
|
10572
|
+
const cleanup = () => {
|
|
10573
|
+
this.observer?.disconnect();
|
|
10574
|
+
this.observer = null;
|
|
10575
|
+
this.elementHandlerMap.clear();
|
|
10576
|
+
};
|
|
10577
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
10578
|
+
entries.forEach((entry) => {
|
|
10579
|
+
const handler = this.elementHandlerMap.get(entry.target);
|
|
10580
|
+
handler?.(entry);
|
|
10170
10581
|
});
|
|
10171
|
-
}
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10176
|
-
|
|
10582
|
+
}, {
|
|
10583
|
+
root: viewportElement,
|
|
10584
|
+
...options,
|
|
10585
|
+
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
10586
|
+
});
|
|
10587
|
+
if (this.queueSet.size) {
|
|
10588
|
+
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
10589
|
+
// check if element which requested observation is
|
|
10590
|
+
// a child of a viewport element, skip if isn't
|
|
10591
|
+
if (!viewportElement.contains(queueElement))
|
|
10592
|
+
return;
|
|
10593
|
+
this.observer.observe(queueElement);
|
|
10594
|
+
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
10177
10595
|
});
|
|
10596
|
+
this.queueSet.clear();
|
|
10178
10597
|
}
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10598
|
+
return cleanup;
|
|
10599
|
+
};
|
|
10600
|
+
/**
|
|
10601
|
+
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
10602
|
+
* detects a possible change in element's visibility within specified viewport, returns
|
|
10603
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10604
|
+
*/
|
|
10605
|
+
this.observe = (element, handler) => {
|
|
10606
|
+
const queueItem = [element, handler];
|
|
10607
|
+
const cleanup = () => {
|
|
10608
|
+
this.elementHandlerMap.delete(element);
|
|
10609
|
+
this.observer?.unobserve(element);
|
|
10610
|
+
this.queueSet.delete(queueItem);
|
|
10611
|
+
};
|
|
10612
|
+
if (this.elementHandlerMap.has(element))
|
|
10613
|
+
return cleanup;
|
|
10614
|
+
if (!this.observer) {
|
|
10615
|
+
this.queueSet.add(queueItem);
|
|
10616
|
+
return cleanup;
|
|
10617
|
+
}
|
|
10618
|
+
if (this.observer.root.contains(element)) {
|
|
10619
|
+
this.elementHandlerMap.set(element, handler);
|
|
10620
|
+
this.observer.observe(element);
|
|
10621
|
+
}
|
|
10622
|
+
return cleanup;
|
|
10623
|
+
};
|
|
10624
|
+
/**
|
|
10625
|
+
* Tracks the given element for visibility changes and mirrors the result
|
|
10626
|
+
* into `participant.viewportVisibilityState[trackType]` in `CallState`.
|
|
10627
|
+
* Returns a function that unobserves the element and resets the visibility
|
|
10628
|
+
* state back to `UNKNOWN`.
|
|
10629
|
+
*/
|
|
10630
|
+
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
10631
|
+
const cleanup = this.observe(element, (entry) => {
|
|
10632
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10633
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10634
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10635
|
+
// observer triggers when the element is "moved" to be a fullscreen element
|
|
10636
|
+
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
10637
|
+
const isVisible = entry.isIntersecting || document.fullscreenElement === element
|
|
10638
|
+
? exports.VisibilityState.VISIBLE
|
|
10639
|
+
: exports.VisibilityState.INVISIBLE;
|
|
10640
|
+
return {
|
|
10641
|
+
...participant,
|
|
10642
|
+
viewportVisibilityState: {
|
|
10643
|
+
...previousVisibilityState,
|
|
10644
|
+
[trackType]: isVisible,
|
|
10645
|
+
},
|
|
10646
|
+
};
|
|
10647
|
+
});
|
|
10648
|
+
});
|
|
10649
|
+
return () => {
|
|
10650
|
+
cleanup();
|
|
10651
|
+
// reset visibility state to UNKNOWN upon cleanup
|
|
10652
|
+
// so that the layouts that are not actively observed
|
|
10653
|
+
// can still function normally (runtime layout switching)
|
|
10654
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10655
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10656
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10657
|
+
return {
|
|
10658
|
+
...participant,
|
|
10659
|
+
viewportVisibilityState: {
|
|
10660
|
+
...previousVisibilityState,
|
|
10661
|
+
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
10662
|
+
},
|
|
10663
|
+
};
|
|
10664
|
+
});
|
|
10665
|
+
};
|
|
10666
|
+
};
|
|
10667
|
+
this.callState = callState;
|
|
10184
10668
|
}
|
|
10185
10669
|
}
|
|
10186
10670
|
|
|
@@ -10344,6 +10828,50 @@ const CallTypes = new CallTypesRegistry([
|
|
|
10344
10828
|
}),
|
|
10345
10829
|
]);
|
|
10346
10830
|
|
|
10831
|
+
/**
|
|
10832
|
+
* A generic sliding-window rate limiter.
|
|
10833
|
+
*
|
|
10834
|
+
* Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
|
|
10835
|
+
* Attempts spaced further apart than `windowMs` are always allowed.
|
|
10836
|
+
*/
|
|
10837
|
+
class SlidingWindowRateLimiter {
|
|
10838
|
+
constructor(maxAttempts, windowMs) {
|
|
10839
|
+
this.timestamps = [];
|
|
10840
|
+
/**
|
|
10841
|
+
* Attempts to register a new event at `now`. Returns `true` if the attempt
|
|
10842
|
+
* fits inside the budget (and records it), or `false` if the budget is
|
|
10843
|
+
* exhausted (in which case no timestamp is recorded).
|
|
10844
|
+
*/
|
|
10845
|
+
this.tryRegister = (now = Date.now()) => {
|
|
10846
|
+
this.prune(now);
|
|
10847
|
+
if (this.timestamps.length >= this.maxAttempts)
|
|
10848
|
+
return false;
|
|
10849
|
+
this.timestamps.push(now);
|
|
10850
|
+
return true;
|
|
10851
|
+
};
|
|
10852
|
+
/**
|
|
10853
|
+
* Clears the attempt history.
|
|
10854
|
+
*/
|
|
10855
|
+
this.reset = () => {
|
|
10856
|
+
this.timestamps = [];
|
|
10857
|
+
};
|
|
10858
|
+
/**
|
|
10859
|
+
* Updates the budget and window size. Existing timestamps are kept; they
|
|
10860
|
+
* will be pruned by the next `tryRegister` call.
|
|
10861
|
+
*/
|
|
10862
|
+
this.setLimits = (maxAttempts, windowMs) => {
|
|
10863
|
+
this.maxAttempts = maxAttempts;
|
|
10864
|
+
this.windowMs = windowMs;
|
|
10865
|
+
};
|
|
10866
|
+
this.prune = (now) => {
|
|
10867
|
+
const cutoff = now - this.windowMs;
|
|
10868
|
+
this.timestamps = this.timestamps.filter((t) => t >= cutoff);
|
|
10869
|
+
};
|
|
10870
|
+
this.maxAttempts = maxAttempts;
|
|
10871
|
+
this.windowMs = windowMs;
|
|
10872
|
+
}
|
|
10873
|
+
}
|
|
10874
|
+
|
|
10347
10875
|
/**
|
|
10348
10876
|
* Deactivates MediaStream (stops and removes tracks) to be later garbage collected
|
|
10349
10877
|
*
|
|
@@ -10725,7 +11253,6 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10725
11253
|
const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
|
|
10726
11254
|
try {
|
|
10727
11255
|
const constraints = {
|
|
10728
|
-
// @ts-expect-error - not present in types yet
|
|
10729
11256
|
systemAudio: 'include',
|
|
10730
11257
|
...options,
|
|
10731
11258
|
video: typeof options?.video === 'boolean'
|
|
@@ -10740,6 +11267,8 @@ const getScreenShareStream = async (options, tracer) => {
|
|
|
10740
11267
|
? options.audio
|
|
10741
11268
|
: {
|
|
10742
11269
|
channelCount: { ideal: 2 },
|
|
11270
|
+
// @ts-expect-error not yet present in the types
|
|
11271
|
+
restrictOwnAudio: true,
|
|
10743
11272
|
echoCancellation: false,
|
|
10744
11273
|
autoGainControl: false,
|
|
10745
11274
|
noiseSuppression: false,
|
|
@@ -10853,6 +11382,7 @@ class DeviceManager {
|
|
|
10853
11382
|
*/
|
|
10854
11383
|
this.stopOnLeave = true;
|
|
10855
11384
|
this.subscriptions = [];
|
|
11385
|
+
this.currentStreamCleanups = [];
|
|
10856
11386
|
this.areSubscriptionsSetUp = false;
|
|
10857
11387
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
10858
11388
|
this.filters = [];
|
|
@@ -10864,10 +11394,30 @@ class DeviceManager {
|
|
|
10864
11394
|
* @internal
|
|
10865
11395
|
*/
|
|
10866
11396
|
this.dispose = () => {
|
|
11397
|
+
this.runCurrentStreamCleanups();
|
|
10867
11398
|
this.subscriptions.forEach((s) => s());
|
|
10868
11399
|
this.subscriptions = [];
|
|
10869
11400
|
this.areSubscriptionsSetUp = false;
|
|
10870
11401
|
};
|
|
11402
|
+
this.runCurrentStreamCleanups = () => {
|
|
11403
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
11404
|
+
this.currentStreamCleanups = [];
|
|
11405
|
+
};
|
|
11406
|
+
this.setLocalInterrupted = (interrupted) => {
|
|
11407
|
+
const localParticipant = this.call.state.localParticipant;
|
|
11408
|
+
if (!localParticipant)
|
|
11409
|
+
return;
|
|
11410
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
11411
|
+
const current = p.interruptedTracks ?? [];
|
|
11412
|
+
const has = current.includes(this.trackType);
|
|
11413
|
+
if (interrupted === has)
|
|
11414
|
+
return {};
|
|
11415
|
+
const next = interrupted
|
|
11416
|
+
? pushToIfMissing([...current], this.trackType)
|
|
11417
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
11418
|
+
return { interruptedTracks: next };
|
|
11419
|
+
});
|
|
11420
|
+
};
|
|
10871
11421
|
this.call = call;
|
|
10872
11422
|
this.state = state;
|
|
10873
11423
|
this.trackType = trackType;
|
|
@@ -11091,7 +11641,9 @@ class DeviceManager {
|
|
|
11091
11641
|
// @ts-expect-error called to dispose the stream in RN
|
|
11092
11642
|
mediaStream.release();
|
|
11093
11643
|
}
|
|
11644
|
+
this.runCurrentStreamCleanups();
|
|
11094
11645
|
this.state.setMediaStream(undefined, undefined);
|
|
11646
|
+
this.setLocalInterrupted(false);
|
|
11095
11647
|
this.filters.forEach((entry) => entry.stop?.());
|
|
11096
11648
|
}
|
|
11097
11649
|
}
|
|
@@ -11127,13 +11679,17 @@ class DeviceManager {
|
|
|
11127
11679
|
async unmuteStream() {
|
|
11128
11680
|
this.logger.debug('Starting stream');
|
|
11129
11681
|
let stream;
|
|
11130
|
-
let
|
|
11682
|
+
let rootStreamPromise;
|
|
11131
11683
|
if (this.state.mediaStream &&
|
|
11132
11684
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
11133
11685
|
stream = this.state.mediaStream;
|
|
11134
11686
|
this.enableTracks();
|
|
11135
11687
|
}
|
|
11136
11688
|
else {
|
|
11689
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
11690
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
11691
|
+
// before chainWith below registers new ones for the new chain.
|
|
11692
|
+
this.runCurrentStreamCleanups();
|
|
11137
11693
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11138
11694
|
const constraints = {
|
|
11139
11695
|
...defaultConstraints,
|
|
@@ -11189,7 +11745,7 @@ class DeviceManager {
|
|
|
11189
11745
|
});
|
|
11190
11746
|
};
|
|
11191
11747
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
11192
|
-
this.
|
|
11748
|
+
this.currentStreamCleanups.push(() => {
|
|
11193
11749
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
11194
11750
|
});
|
|
11195
11751
|
});
|
|
@@ -11197,7 +11753,7 @@ class DeviceManager {
|
|
|
11197
11753
|
};
|
|
11198
11754
|
// the rootStream represents the stream coming from the actual device
|
|
11199
11755
|
// e.g. camera or microphone stream
|
|
11200
|
-
|
|
11756
|
+
rootStreamPromise = this.getStream(constraints);
|
|
11201
11757
|
// we publish the last MediaStream of the chain
|
|
11202
11758
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11203
11759
|
.then((inputStream) => {
|
|
@@ -11207,43 +11763,71 @@ class DeviceManager {
|
|
|
11207
11763
|
})
|
|
11208
11764
|
.then(chainWith(parent), (error) => {
|
|
11209
11765
|
this.logger.warn('Filter failed to start and will be ignored', error);
|
|
11210
|
-
return parent;
|
|
11211
|
-
}),
|
|
11212
|
-
}
|
|
11213
|
-
if (this.call.state.callingState === exports.CallingState.JOINED) {
|
|
11214
|
-
await this.publishStream(stream);
|
|
11215
|
-
}
|
|
11216
|
-
if (this.state.mediaStream !== stream) {
|
|
11217
|
-
|
|
11218
|
-
|
|
11219
|
-
|
|
11220
|
-
|
|
11221
|
-
this.
|
|
11222
|
-
|
|
11223
|
-
|
|
11224
|
-
|
|
11225
|
-
|
|
11226
|
-
|
|
11227
|
-
|
|
11228
|
-
|
|
11229
|
-
|
|
11230
|
-
|
|
11231
|
-
|
|
11232
|
-
this.
|
|
11233
|
-
|
|
11234
|
-
|
|
11235
|
-
|
|
11236
|
-
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
11240
|
-
|
|
11241
|
-
|
|
11242
|
-
|
|
11243
|
-
|
|
11244
|
-
|
|
11766
|
+
return parent;
|
|
11767
|
+
}), rootStreamPromise);
|
|
11768
|
+
}
|
|
11769
|
+
if (this.call.state.callingState === exports.CallingState.JOINED) {
|
|
11770
|
+
await this.publishStream(stream);
|
|
11771
|
+
}
|
|
11772
|
+
if (this.state.mediaStream !== stream) {
|
|
11773
|
+
const rootStream = await rootStreamPromise;
|
|
11774
|
+
this.state.setMediaStream(stream, rootStream);
|
|
11775
|
+
if (rootStream) {
|
|
11776
|
+
const handleTrackEnded = async () => {
|
|
11777
|
+
this.setLocalInterrupted(false);
|
|
11778
|
+
await this.statusChangeSettled();
|
|
11779
|
+
if (this.enabled) {
|
|
11780
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
11781
|
+
setTimeout(() => {
|
|
11782
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
11783
|
+
}, 2000);
|
|
11784
|
+
await this.disable();
|
|
11785
|
+
}
|
|
11786
|
+
};
|
|
11787
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
11788
|
+
this.setLocalInterrupted(muted);
|
|
11789
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
11790
|
+
// macOS audio session interruption even though the track is
|
|
11791
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
11792
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
11793
|
+
// page is hidden because the encoder won't resume until
|
|
11794
|
+
// foreground anyway.
|
|
11795
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
11796
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
11797
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
11798
|
+
});
|
|
11799
|
+
}
|
|
11800
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
11801
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
11802
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
11803
|
+
trackType: TrackType[this.trackType],
|
|
11804
|
+
muted,
|
|
11805
|
+
});
|
|
11806
|
+
this.call
|
|
11807
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
11808
|
+
.catch((err) => {
|
|
11809
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
11810
|
+
});
|
|
11811
|
+
}
|
|
11812
|
+
};
|
|
11813
|
+
rootStream.getTracks().forEach((track) => {
|
|
11814
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
11815
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
11816
|
+
track.addEventListener('mute', muteHandler);
|
|
11817
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
11818
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
11819
|
+
this.currentStreamCleanups.push(() => {
|
|
11820
|
+
track.removeEventListener('mute', muteHandler);
|
|
11821
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
11822
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
11823
|
+
});
|
|
11245
11824
|
});
|
|
11246
|
-
|
|
11825
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
11826
|
+
this.setLocalInterrupted(initialMuted);
|
|
11827
|
+
}
|
|
11828
|
+
else {
|
|
11829
|
+
this.setLocalInterrupted(false);
|
|
11830
|
+
}
|
|
11247
11831
|
}
|
|
11248
11832
|
}
|
|
11249
11833
|
get mediaDeviceKind() {
|
|
@@ -11384,13 +11968,19 @@ class DeviceManagerState {
|
|
|
11384
11968
|
this.statusSubject = new rxjs.BehaviorSubject(undefined);
|
|
11385
11969
|
this.optimisticStatusSubject = new rxjs.BehaviorSubject(undefined);
|
|
11386
11970
|
this.mediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
11971
|
+
this.rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
11387
11972
|
this.selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
|
|
11388
11973
|
this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
11389
11974
|
/**
|
|
11390
11975
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
11391
|
-
*
|
|
11392
11976
|
*/
|
|
11393
11977
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11978
|
+
/**
|
|
11979
|
+
* An Observable that emits the raw device media stream (before any filters are applied),
|
|
11980
|
+
* or `undefined` if the device is currently disabled. When no filters are active, this
|
|
11981
|
+
* emits the same stream as `mediaStream$`.
|
|
11982
|
+
*/
|
|
11983
|
+
this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
|
|
11394
11984
|
/**
|
|
11395
11985
|
* An Observable that emits the currently selected device
|
|
11396
11986
|
*/
|
|
@@ -11446,6 +12036,14 @@ class DeviceManagerState {
|
|
|
11446
12036
|
get mediaStream() {
|
|
11447
12037
|
return getCurrentValue(this.mediaStream$);
|
|
11448
12038
|
}
|
|
12039
|
+
/**
|
|
12040
|
+
* The raw device media stream (before any filters are applied), or `undefined`
|
|
12041
|
+
* if the device is currently disabled. When no filters are active, this is the
|
|
12042
|
+
* same as `mediaStream`.
|
|
12043
|
+
*/
|
|
12044
|
+
get rootMediaStream() {
|
|
12045
|
+
return getCurrentValue(this.rootMediaStream$);
|
|
12046
|
+
}
|
|
11449
12047
|
/**
|
|
11450
12048
|
* @internal
|
|
11451
12049
|
* @param status
|
|
@@ -11470,6 +12068,7 @@ class DeviceManagerState {
|
|
|
11470
12068
|
*/
|
|
11471
12069
|
setMediaStream(stream, rootStream) {
|
|
11472
12070
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
12071
|
+
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
11473
12072
|
if (rootStream) {
|
|
11474
12073
|
this.setDevice(this.getDeviceIdFromStream(rootStream));
|
|
11475
12074
|
}
|
|
@@ -12721,6 +13320,16 @@ class Call {
|
|
|
12721
13320
|
this.fastReconnectDeadlineSeconds = 0;
|
|
12722
13321
|
this.disconnectionTimeoutSeconds = 0;
|
|
12723
13322
|
this.lastOfflineTimestamp = 0;
|
|
13323
|
+
// (10 attempts per rolling 120 s window).
|
|
13324
|
+
this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
13325
|
+
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
13326
|
+
// failures where ICE never reached `connected`/`completed`.
|
|
13327
|
+
this.maxIceFailuresWithoutConnect = 2;
|
|
13328
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13329
|
+
// Consecutive-negotiation-failure detector: stops the reconnect loop when
|
|
13330
|
+
// the SFU keeps failing to negotiate SDP for us.
|
|
13331
|
+
this.maxConsecutiveNegotiationFailures = 3;
|
|
13332
|
+
this.consecutiveNegotiationFailures = 0;
|
|
12724
13333
|
// maintain the order of publishing tracks to restore them after a reconnection
|
|
12725
13334
|
// it shouldn't contain duplicates
|
|
12726
13335
|
this.trackPublishOrder = [];
|
|
@@ -13018,11 +13627,26 @@ class Call {
|
|
|
13018
13627
|
this.publisher = undefined;
|
|
13019
13628
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13020
13629
|
this.sfuClient = undefined;
|
|
13021
|
-
this.
|
|
13022
|
-
|
|
13630
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
13631
|
+
this.trackSubscriptionManager.dispose();
|
|
13632
|
+
this.audioBindingsWatchdog?.dispose();
|
|
13633
|
+
await this.dynascaleManager?.dispose();
|
|
13023
13634
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
13024
13635
|
this.state.setParticipants([]);
|
|
13025
13636
|
this.state.dispose();
|
|
13637
|
+
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
13638
|
+
// same instance starts with a fresh budget. The `Call` may be reused
|
|
13639
|
+
// (see `Call.test.ts` "can reuse call instance") so this is required.
|
|
13640
|
+
// Strategy/reason/attempts must also be cleared: when `leave()` is
|
|
13641
|
+
// reached via `giveUpAndLeave()` the success-path reset at the end of
|
|
13642
|
+
// `joinFlow` never runs, leaving stale values that would make the next
|
|
13643
|
+
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
13644
|
+
this.rejoinRateLimiter.reset();
|
|
13645
|
+
this.iceFailuresWithoutConnect = 0;
|
|
13646
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13647
|
+
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13648
|
+
this.reconnectReason = '';
|
|
13649
|
+
this.reconnectAttempts = 0;
|
|
13026
13650
|
// Call all leave call hooks, e.g. to clean up global event handlers
|
|
13027
13651
|
this.leaveCallHooks.forEach((hook) => hook());
|
|
13028
13652
|
this.initialized = false;
|
|
@@ -13316,7 +13940,7 @@ class Call {
|
|
|
13316
13940
|
: previousSfuClient;
|
|
13317
13941
|
this.sfuClient = sfuClient;
|
|
13318
13942
|
this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
|
|
13319
|
-
this.
|
|
13943
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
13320
13944
|
const clientDetails = await getClientDetails();
|
|
13321
13945
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
13322
13946
|
if (previousSfuClient !== sfuClient) {
|
|
@@ -13373,9 +13997,20 @@ class Call {
|
|
|
13373
13997
|
// when performing fast reconnect, or when we reuse the same SFU client,
|
|
13374
13998
|
// (ws remained healthy), we just need to restore the ICE connection
|
|
13375
13999
|
if (performingFastReconnect) {
|
|
13376
|
-
//
|
|
13377
|
-
// we
|
|
13378
|
-
|
|
14000
|
+
// The SFU automatically issues an ICE restart on the subscriber,
|
|
14001
|
+
// so we only need to decide about the publisher. If the publisher's
|
|
14002
|
+
// peer connection is still stable (ICE still connected end-to-end),
|
|
14003
|
+
// the signal WebSocket drop was the only problem — the new WS alone
|
|
14004
|
+
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
|
|
14005
|
+
const publisherIsStable = this.publisher?.isStable() ?? true;
|
|
14006
|
+
const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
|
|
14007
|
+
if (!includePublisher && this.publisher?.isPublishing()) {
|
|
14008
|
+
this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
|
|
14009
|
+
}
|
|
14010
|
+
await this.restoreICE(sfuClient, {
|
|
14011
|
+
includeSubscriber: false,
|
|
14012
|
+
includePublisher,
|
|
14013
|
+
});
|
|
13379
14014
|
}
|
|
13380
14015
|
else {
|
|
13381
14016
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
@@ -13418,6 +14053,15 @@ class Call {
|
|
|
13418
14053
|
// reset the reconnect strategy to unspecified after a successful reconnection
|
|
13419
14054
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
13420
14055
|
this.reconnectReason = '';
|
|
14056
|
+
// A successful SFU join handshake resets the consecutive-negotiation
|
|
14057
|
+
// counter (negotiation just succeeded). It does NOT reset
|
|
14058
|
+
// `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
|
|
14059
|
+
// those track WebRTC-level health and rejoin frequency, which are not
|
|
14060
|
+
// proven by the SFU handshake alone. ICE-failures-without-connect is
|
|
14061
|
+
// cleared via the `onIceConnected` callback when the peer connection
|
|
14062
|
+
// actually reaches `connected`/`completed` end-to-end. The rejoin
|
|
14063
|
+
// rolling window decays naturally as old timestamps age out.
|
|
14064
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13421
14065
|
this.logger.info(`Joined call ${this.cid}`);
|
|
13422
14066
|
};
|
|
13423
14067
|
/**
|
|
@@ -13431,7 +14075,7 @@ class Call {
|
|
|
13431
14075
|
return {
|
|
13432
14076
|
strategy,
|
|
13433
14077
|
announcedTracks,
|
|
13434
|
-
subscriptions: this.
|
|
14078
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
13435
14079
|
reconnectAttempt: this.reconnectAttempts,
|
|
13436
14080
|
fromSfuId: migratingFromSfuId || '',
|
|
13437
14081
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -13535,6 +14179,12 @@ class Call {
|
|
|
13535
14179
|
this.logger.warn(message, err);
|
|
13536
14180
|
});
|
|
13537
14181
|
},
|
|
14182
|
+
onIceConnected: () => {
|
|
14183
|
+
// ICE has reached `connected`/`completed` end-to-end on at least
|
|
14184
|
+
// one peer connection, WebRTC is actually working, so the
|
|
14185
|
+
// "ICE never connected" failure budget can be cleared.
|
|
14186
|
+
this.iceFailuresWithoutConnect = 0;
|
|
14187
|
+
},
|
|
13538
14188
|
};
|
|
13539
14189
|
this.subscriber = new Subscriber(basePeerConnectionOptions);
|
|
13540
14190
|
// anonymous users can't publish anything hence, there is no need
|
|
@@ -13640,7 +14290,9 @@ class Call {
|
|
|
13640
14290
|
* @internal
|
|
13641
14291
|
*
|
|
13642
14292
|
* @param strategy the reconnection strategy to use.
|
|
13643
|
-
* @param reason the reason for the reconnection.
|
|
14293
|
+
* @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
|
|
14294
|
+
* constant when the SDK should react to it (e.g.
|
|
14295
|
+
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
13644
14296
|
*/
|
|
13645
14297
|
this.reconnect = async (strategy, reason) => {
|
|
13646
14298
|
if (this.state.callingState === exports.CallingState.RECONNECTING ||
|
|
@@ -13661,6 +14313,30 @@ class Call {
|
|
|
13661
14313
|
this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
|
|
13662
14314
|
}
|
|
13663
14315
|
};
|
|
14316
|
+
const giveUpAndLeave = async (message) => {
|
|
14317
|
+
this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
|
|
14318
|
+
// If we're mid-iteration, the state can be JOINING; `Call.leave` would
|
|
14319
|
+
// then wait for JOINED before proceeding, but no more attempts will run
|
|
14320
|
+
// so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
|
|
14321
|
+
if (this.state.callingState === exports.CallingState.JOINING) {
|
|
14322
|
+
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
14323
|
+
}
|
|
14324
|
+
try {
|
|
14325
|
+
await this.leave({ message });
|
|
14326
|
+
}
|
|
14327
|
+
catch (err) {
|
|
14328
|
+
this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
|
|
14329
|
+
}
|
|
14330
|
+
};
|
|
14331
|
+
// Count this entry into reconnect if it was triggered by a peer
|
|
14332
|
+
// connection that never reached `connected`/`completed`.
|
|
14333
|
+
if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
|
|
14334
|
+
this.iceFailuresWithoutConnect++;
|
|
14335
|
+
if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
|
|
14336
|
+
await giveUpAndLeave('webrtc_unsupported_network');
|
|
14337
|
+
return;
|
|
14338
|
+
}
|
|
14339
|
+
}
|
|
13664
14340
|
let attempt = 0;
|
|
13665
14341
|
do {
|
|
13666
14342
|
const reconnectingTime = Date.now() - reconnectStartTime;
|
|
@@ -13671,6 +14347,16 @@ class Call {
|
|
|
13671
14347
|
await markAsReconnectingFailed();
|
|
13672
14348
|
return;
|
|
13673
14349
|
}
|
|
14350
|
+
// Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
|
|
14351
|
+
// transitions inside a rolling window. FAST is not counted because
|
|
14352
|
+
// it does not issue a new backend `joinCall`.
|
|
14353
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
|
|
14354
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
|
|
14355
|
+
if (!this.rejoinRateLimiter.tryRegister()) {
|
|
14356
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
14357
|
+
return;
|
|
14358
|
+
}
|
|
14359
|
+
}
|
|
13674
14360
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
13675
14361
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
13676
14362
|
this.reconnectAttempts++;
|
|
@@ -13698,6 +14384,8 @@ class Call {
|
|
|
13698
14384
|
ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
|
|
13699
14385
|
break;
|
|
13700
14386
|
}
|
|
14387
|
+
// reconnection worked — reset the negotiation-failure streak.
|
|
14388
|
+
this.consecutiveNegotiationFailures = 0;
|
|
13701
14389
|
break; // do-while loop, reconnection worked, exit the loop
|
|
13702
14390
|
}
|
|
13703
14391
|
catch (error) {
|
|
@@ -13712,7 +14400,16 @@ class Call {
|
|
|
13712
14400
|
await markAsReconnectingFailed();
|
|
13713
14401
|
return;
|
|
13714
14402
|
}
|
|
13715
|
-
|
|
14403
|
+
if (error instanceof NegotiationError) {
|
|
14404
|
+
this.consecutiveNegotiationFailures++;
|
|
14405
|
+
if (this.consecutiveNegotiationFailures >=
|
|
14406
|
+
this.maxConsecutiveNegotiationFailures) {
|
|
14407
|
+
await giveUpAndLeave('repeated_negotiation_failures');
|
|
14408
|
+
return;
|
|
14409
|
+
}
|
|
14410
|
+
}
|
|
14411
|
+
// exponential backoff with jitter, capped at 5 s
|
|
14412
|
+
await sleep(retryInterval(attempt));
|
|
13716
14413
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13717
14414
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
13718
14415
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -13821,7 +14518,7 @@ class Call {
|
|
|
13821
14518
|
this.registerReconnectHandlers = () => {
|
|
13822
14519
|
// handles the legacy "goAway" event
|
|
13823
14520
|
const unregisterGoAway = this.on('goAway', () => {
|
|
13824
|
-
this.reconnect(WebsocketReconnectStrategy.MIGRATE,
|
|
14521
|
+
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
13825
14522
|
});
|
|
13826
14523
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
13827
14524
|
const unregisterOnError = this.on('error', (e) => {
|
|
@@ -13840,7 +14537,7 @@ class Call {
|
|
|
13840
14537
|
});
|
|
13841
14538
|
}
|
|
13842
14539
|
else {
|
|
13843
|
-
this.reconnect(strategy, error?.message ||
|
|
14540
|
+
this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
|
|
13844
14541
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
13845
14542
|
});
|
|
13846
14543
|
}
|
|
@@ -13864,7 +14561,7 @@ class Call {
|
|
|
13864
14561
|
strategy = WebsocketReconnectStrategy.REJOIN;
|
|
13865
14562
|
}
|
|
13866
14563
|
}
|
|
13867
|
-
this.reconnect(strategy,
|
|
14564
|
+
this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
|
|
13868
14565
|
this.logger.warn('[Reconnect] Error reconnecting after going online', err);
|
|
13869
14566
|
});
|
|
13870
14567
|
});
|
|
@@ -13925,7 +14622,7 @@ class Call {
|
|
|
13925
14622
|
const { remoteParticipants } = this.state;
|
|
13926
14623
|
if (remoteParticipants.length <= 0)
|
|
13927
14624
|
return;
|
|
13928
|
-
this.
|
|
14625
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
13929
14626
|
};
|
|
13930
14627
|
/**
|
|
13931
14628
|
* Starts publishing the given video stream to the call.
|
|
@@ -14007,10 +14704,12 @@ class Call {
|
|
|
14007
14704
|
* @param trackTypes the track types to update the call state with.
|
|
14008
14705
|
*/
|
|
14009
14706
|
this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
|
|
14010
|
-
|
|
14707
|
+
const sessionId = this.sfuClient?.sessionId;
|
|
14708
|
+
if (!sessionId)
|
|
14011
14709
|
return;
|
|
14012
14710
|
await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
|
|
14013
|
-
|
|
14711
|
+
if (this.sfuClient?.sessionId !== sessionId)
|
|
14712
|
+
return;
|
|
14014
14713
|
for (const trackType of trackTypes) {
|
|
14015
14714
|
const streamStateProp = trackTypeToParticipantStreamKey(trackType);
|
|
14016
14715
|
if (!streamStateProp)
|
|
@@ -14023,6 +14722,20 @@ class Call {
|
|
|
14023
14722
|
}));
|
|
14024
14723
|
}
|
|
14025
14724
|
};
|
|
14725
|
+
/**
|
|
14726
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
14727
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
14728
|
+
* interruption (Siri, PSTN call).
|
|
14729
|
+
*
|
|
14730
|
+
* @internal
|
|
14731
|
+
*
|
|
14732
|
+
* @param trackType the track type to refresh.
|
|
14733
|
+
*/
|
|
14734
|
+
this.refreshPublishedTrack = async (trackType) => {
|
|
14735
|
+
if (!this.publisher)
|
|
14736
|
+
return;
|
|
14737
|
+
await this.publisher.refreshTrack(trackType);
|
|
14738
|
+
};
|
|
14026
14739
|
/**
|
|
14027
14740
|
* Updates the preferred publishing options
|
|
14028
14741
|
*
|
|
@@ -14684,7 +15397,7 @@ class Call {
|
|
|
14684
15397
|
* @param trackType the video mode.
|
|
14685
15398
|
*/
|
|
14686
15399
|
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
14687
|
-
return this.
|
|
15400
|
+
return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
|
|
14688
15401
|
};
|
|
14689
15402
|
/**
|
|
14690
15403
|
* Sets the viewport element to track bound video elements for visibility.
|
|
@@ -14692,7 +15405,7 @@ class Call {
|
|
|
14692
15405
|
* @param element the viewport element.
|
|
14693
15406
|
*/
|
|
14694
15407
|
this.setViewport = (element) => {
|
|
14695
|
-
return this.
|
|
15408
|
+
return this.viewportTracker?.setViewport(element);
|
|
14696
15409
|
};
|
|
14697
15410
|
/**
|
|
14698
15411
|
* Binds a DOM <video> element to the given session id.
|
|
@@ -14710,7 +15423,7 @@ class Call {
|
|
|
14710
15423
|
* @param trackType the kind of video.
|
|
14711
15424
|
*/
|
|
14712
15425
|
this.bindVideoElement = (videoElement, sessionId, trackType) => {
|
|
14713
|
-
const unbind = this.dynascaleManager
|
|
15426
|
+
const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
|
|
14714
15427
|
if (!unbind)
|
|
14715
15428
|
return;
|
|
14716
15429
|
this.leaveCallHooks.add(unbind);
|
|
@@ -14730,21 +15443,28 @@ class Call {
|
|
|
14730
15443
|
* @param trackType the kind of audio.
|
|
14731
15444
|
*/
|
|
14732
15445
|
this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
|
|
14733
|
-
const unbind = this.dynascaleManager
|
|
15446
|
+
const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
|
|
14734
15447
|
if (!unbind)
|
|
14735
15448
|
return;
|
|
14736
|
-
this.
|
|
14737
|
-
|
|
14738
|
-
this.leaveCallHooks.delete(unbind);
|
|
15449
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
15450
|
+
const cleanup = () => {
|
|
14739
15451
|
unbind();
|
|
15452
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
15453
|
+
};
|
|
15454
|
+
this.leaveCallHooks.add(cleanup);
|
|
15455
|
+
return () => {
|
|
15456
|
+
this.leaveCallHooks.delete(cleanup);
|
|
15457
|
+
cleanup();
|
|
14740
15458
|
};
|
|
14741
15459
|
};
|
|
14742
15460
|
/**
|
|
14743
15461
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
15462
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
15463
|
+
*
|
|
15464
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
15465
|
+
* gesture is required.
|
|
14744
15466
|
*/
|
|
14745
|
-
this.resumeAudio = () =>
|
|
14746
|
-
return this.dynascaleManager.resumeAudio();
|
|
14747
|
-
};
|
|
15467
|
+
this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
14748
15468
|
/**
|
|
14749
15469
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
14750
15470
|
*
|
|
@@ -14782,21 +15502,21 @@ class Call {
|
|
|
14782
15502
|
* preference has effect on. Affects all participants by default.
|
|
14783
15503
|
*/
|
|
14784
15504
|
this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
|
|
14785
|
-
this.
|
|
15505
|
+
this.trackSubscriptionManager.setOverrides(resolution
|
|
14786
15506
|
? {
|
|
14787
15507
|
enabled: true,
|
|
14788
15508
|
dimension: resolution,
|
|
14789
15509
|
}
|
|
14790
15510
|
: undefined, sessionIds);
|
|
14791
|
-
this.
|
|
15511
|
+
this.trackSubscriptionManager.apply();
|
|
14792
15512
|
};
|
|
14793
15513
|
/**
|
|
14794
15514
|
* Enables or disables incoming video from all remote call participants,
|
|
14795
15515
|
* and removes any preference for preferred resolution.
|
|
14796
15516
|
*/
|
|
14797
15517
|
this.setIncomingVideoEnabled = (enabled) => {
|
|
14798
|
-
this.
|
|
14799
|
-
this.
|
|
15518
|
+
this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
|
|
15519
|
+
this.trackSubscriptionManager.apply();
|
|
14800
15520
|
};
|
|
14801
15521
|
/**
|
|
14802
15522
|
* Sets the maximum amount of time a user can remain waiting for a reconnect
|
|
@@ -14806,6 +15526,39 @@ class Call {
|
|
|
14806
15526
|
this.setDisconnectionTimeout = (timeoutSeconds) => {
|
|
14807
15527
|
this.disconnectionTimeoutSeconds = timeoutSeconds;
|
|
14808
15528
|
};
|
|
15529
|
+
/**
|
|
15530
|
+
* Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
|
|
15531
|
+
* `maxAttempts` rejoins have been registered inside `windowSeconds`, the
|
|
15532
|
+
* SDK stops retrying and transitions the call to `LEFT` with the
|
|
15533
|
+
* `rejoin_attempt_limit_exceeded` leave message.
|
|
15534
|
+
*
|
|
15535
|
+
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
15536
|
+
* Both arguments are clamped to a minimum of 1.
|
|
15537
|
+
*/
|
|
15538
|
+
this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
15539
|
+
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
15540
|
+
};
|
|
15541
|
+
/**
|
|
15542
|
+
* Configures how many peer-connection failures where ICE never reached
|
|
15543
|
+
* `connected`/`completed` are tolerated before the SDK concludes that the
|
|
15544
|
+
* current network cannot support WebRTC and transitions the call to
|
|
15545
|
+
* `LEFT` with the `webrtc_unsupported_network` leave message.
|
|
15546
|
+
*
|
|
15547
|
+
* Default: 2. Clamped to a minimum of 1.
|
|
15548
|
+
*/
|
|
15549
|
+
this.setMaxIceFailuresWithoutConnect = (n) => {
|
|
15550
|
+
this.maxIceFailuresWithoutConnect = Math.max(1, n);
|
|
15551
|
+
};
|
|
15552
|
+
/**
|
|
15553
|
+
* Configures how many consecutive SDP `NegotiationError`s are tolerated
|
|
15554
|
+
* before the SDK stops retrying and transitions the call to `LEFT` with
|
|
15555
|
+
* the `repeated_negotiation_failures` leave message.
|
|
15556
|
+
*
|
|
15557
|
+
* Default: 3. Clamped to a minimum of 1.
|
|
15558
|
+
*/
|
|
15559
|
+
this.setMaxConsecutiveNegotiationFailures = (n) => {
|
|
15560
|
+
this.maxConsecutiveNegotiationFailures = Math.max(1, n);
|
|
15561
|
+
};
|
|
14809
15562
|
/**
|
|
14810
15563
|
* Enables the provided client capabilities.
|
|
14811
15564
|
*/
|
|
@@ -14844,7 +15597,13 @@ class Call {
|
|
|
14844
15597
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
14845
15598
|
this.speaker = new SpeakerManager(this, preferences);
|
|
14846
15599
|
this.screenShare = new ScreenShareManager(this);
|
|
14847
|
-
this.
|
|
15600
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
|
|
15601
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
15602
|
+
if (typeof document !== 'undefined') {
|
|
15603
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
|
|
15604
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
15605
|
+
this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
|
|
15606
|
+
}
|
|
14848
15607
|
}
|
|
14849
15608
|
/**
|
|
14850
15609
|
* A flag indicating whether the call is "ringing" type of call.
|
|
@@ -14917,12 +15676,118 @@ const APIErrorCodes = {
|
|
|
14917
15676
|
*/
|
|
14918
15677
|
class StableWSConnection {
|
|
14919
15678
|
constructor(client) {
|
|
15679
|
+
/** Incremented when a new WS connection is made */
|
|
15680
|
+
this.wsID = 1;
|
|
15681
|
+
// Connection lifecycle flags.
|
|
15682
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15683
|
+
this.isConnecting = false;
|
|
15684
|
+
/** To avoid reconnect if client is disconnected */
|
|
15685
|
+
this.isDisconnected = false;
|
|
15686
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
15687
|
+
this.isHealthy = false;
|
|
15688
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
15689
|
+
this.isConnectionOpenResolved = false;
|
|
15690
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
15691
|
+
/** consecutive failures influence the duration of the timeout */
|
|
15692
|
+
this.consecutiveFailures = 0;
|
|
15693
|
+
/** keep track of the total number of failures */
|
|
15694
|
+
this.totalFailures = 0;
|
|
15695
|
+
// Health-check pings + connection-staleness check.
|
|
15696
|
+
/** Send a health check message every 25 seconds */
|
|
15697
|
+
this.pingInterval = 25 * 1000;
|
|
15698
|
+
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15699
|
+
/** Store the last event time for health checks */
|
|
15700
|
+
this.lastEvent = null;
|
|
14920
15701
|
this._log = (msg, extra = {}, level = 'info') => {
|
|
14921
15702
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
14922
15703
|
};
|
|
14923
15704
|
this.setClient = (client) => {
|
|
14924
15705
|
this.client = client;
|
|
14925
15706
|
};
|
|
15707
|
+
/**
|
|
15708
|
+
* connect - Connect to the WS URL
|
|
15709
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15710
|
+
* @return Promise that completes once the first health check message is received
|
|
15711
|
+
*/
|
|
15712
|
+
this.connect = async (timeout = 15000) => {
|
|
15713
|
+
if (this.isConnecting) {
|
|
15714
|
+
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15715
|
+
}
|
|
15716
|
+
this.isDisconnected = false;
|
|
15717
|
+
try {
|
|
15718
|
+
const healthCheck = await this._connect(timeout);
|
|
15719
|
+
this.consecutiveFailures = 0;
|
|
15720
|
+
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15721
|
+
}
|
|
15722
|
+
catch (caught) {
|
|
15723
|
+
const error = caught;
|
|
15724
|
+
this.isHealthy = false;
|
|
15725
|
+
this.consecutiveFailures += 1;
|
|
15726
|
+
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15727
|
+
!this.client.tokenManager.isStatic()) {
|
|
15728
|
+
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15729
|
+
this._reconnect({ refreshToken: true });
|
|
15730
|
+
}
|
|
15731
|
+
else if (!error.isWSFailure) {
|
|
15732
|
+
// API rejected the connection and we should not retry
|
|
15733
|
+
throw new Error(JSON.stringify({
|
|
15734
|
+
code: error.code,
|
|
15735
|
+
StatusCode: error.StatusCode,
|
|
15736
|
+
message: error.message,
|
|
15737
|
+
isWSFailure: error.isWSFailure,
|
|
15738
|
+
}));
|
|
15739
|
+
}
|
|
15740
|
+
else {
|
|
15741
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
15742
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
15743
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
15744
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
15745
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
15746
|
+
// own catch when _connect was called from there.
|
|
15747
|
+
this._reconnect();
|
|
15748
|
+
}
|
|
15749
|
+
}
|
|
15750
|
+
return await this._waitForHealthy(timeout);
|
|
15751
|
+
};
|
|
15752
|
+
/**
|
|
15753
|
+
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15754
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15755
|
+
* @param timeout duration(ms)
|
|
15756
|
+
*/
|
|
15757
|
+
this._waitForHealthy = async (timeout = 15000) => {
|
|
15758
|
+
return Promise.race([
|
|
15759
|
+
(async () => {
|
|
15760
|
+
const interval = 50; // ms
|
|
15761
|
+
for (let i = 0; i <= timeout; i += interval) {
|
|
15762
|
+
try {
|
|
15763
|
+
return await this.connectionOpen;
|
|
15764
|
+
}
|
|
15765
|
+
catch (caught) {
|
|
15766
|
+
const error = caught;
|
|
15767
|
+
if (i === timeout) {
|
|
15768
|
+
throw new Error(JSON.stringify({
|
|
15769
|
+
code: error.code,
|
|
15770
|
+
StatusCode: error.StatusCode,
|
|
15771
|
+
message: error.message,
|
|
15772
|
+
isWSFailure: error.isWSFailure,
|
|
15773
|
+
}));
|
|
15774
|
+
}
|
|
15775
|
+
await sleep(interval);
|
|
15776
|
+
}
|
|
15777
|
+
}
|
|
15778
|
+
})(),
|
|
15779
|
+
(async () => {
|
|
15780
|
+
await sleep(timeout);
|
|
15781
|
+
this.isConnecting = false;
|
|
15782
|
+
throw new Error(JSON.stringify({
|
|
15783
|
+
code: '',
|
|
15784
|
+
StatusCode: '',
|
|
15785
|
+
message: 'initial WS connection could not be established',
|
|
15786
|
+
isWSFailure: true,
|
|
15787
|
+
}));
|
|
15788
|
+
})(),
|
|
15789
|
+
]);
|
|
15790
|
+
};
|
|
14926
15791
|
/**
|
|
14927
15792
|
* Builds and returns the url for websocket.
|
|
14928
15793
|
* @private
|
|
@@ -14935,11 +15800,166 @@ class StableWSConnection {
|
|
|
14935
15800
|
params.set('X-Stream-Client', this.client.getUserAgent());
|
|
14936
15801
|
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
|
|
14937
15802
|
};
|
|
15803
|
+
/**
|
|
15804
|
+
* disconnect - Disconnect the connection and doesn't recover...
|
|
15805
|
+
*/
|
|
15806
|
+
this.disconnect = (timeout) => {
|
|
15807
|
+
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15808
|
+
this.wsID += 1;
|
|
15809
|
+
this.isConnecting = false;
|
|
15810
|
+
this.isDisconnected = true;
|
|
15811
|
+
// start by removing all the listeners
|
|
15812
|
+
if (this.healthCheckTimeoutRef) {
|
|
15813
|
+
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15814
|
+
}
|
|
15815
|
+
if (this.connectionCheckTimeoutRef) {
|
|
15816
|
+
clearInterval(this.connectionCheckTimeoutRef);
|
|
15817
|
+
}
|
|
15818
|
+
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15819
|
+
this.isHealthy = false;
|
|
15820
|
+
let isClosedPromise;
|
|
15821
|
+
// and finally close...
|
|
15822
|
+
// Assigning to local here because we will remove it from this before the
|
|
15823
|
+
// promise resolves.
|
|
15824
|
+
const { ws } = this;
|
|
15825
|
+
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15826
|
+
isClosedPromise = new Promise((resolve) => {
|
|
15827
|
+
const onclose = (event) => {
|
|
15828
|
+
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15829
|
+
resolve();
|
|
15830
|
+
};
|
|
15831
|
+
ws.onclose = onclose;
|
|
15832
|
+
// In case we don't receive close frame websocket server in time,
|
|
15833
|
+
// lets not wait for more than 1 second.
|
|
15834
|
+
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15835
|
+
});
|
|
15836
|
+
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15837
|
+
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15838
|
+
}
|
|
15839
|
+
else {
|
|
15840
|
+
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15841
|
+
isClosedPromise = Promise.resolve();
|
|
15842
|
+
}
|
|
15843
|
+
delete this.ws;
|
|
15844
|
+
return isClosedPromise;
|
|
15845
|
+
};
|
|
15846
|
+
/**
|
|
15847
|
+
* _connect - Connect to the WS endpoint
|
|
15848
|
+
*
|
|
15849
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
15850
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
15851
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
15852
|
+
* @return Promise that completes once the first health check message is received
|
|
15853
|
+
*/
|
|
15854
|
+
this._connect = async (timeoutMs) => {
|
|
15855
|
+
if (this.isConnecting)
|
|
15856
|
+
return; // ignore _connect if it's currently trying to connect
|
|
15857
|
+
this.isConnecting = true;
|
|
15858
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
15859
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
15860
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
15861
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
15862
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
15863
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
15864
|
+
// captured closure still settles only the original promise (P1) and
|
|
15865
|
+
// never poisons the newer one (P2).
|
|
15866
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15867
|
+
let isTokenReady = false;
|
|
15868
|
+
try {
|
|
15869
|
+
this._log(`_connect() - waiting for token`);
|
|
15870
|
+
await this.client.tokenManager.tokenReady();
|
|
15871
|
+
isTokenReady = true;
|
|
15872
|
+
}
|
|
15873
|
+
catch {
|
|
15874
|
+
// token provider has failed before, so try again
|
|
15875
|
+
}
|
|
15876
|
+
try {
|
|
15877
|
+
if (!isTokenReady) {
|
|
15878
|
+
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15879
|
+
await this.client.tokenManager.loadToken();
|
|
15880
|
+
}
|
|
15881
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
15882
|
+
this.client._setupConnectionIdPromise();
|
|
15883
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
15884
|
+
// closure is the one bound to the promise this attempt owns.
|
|
15885
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
15886
|
+
}
|
|
15887
|
+
this._setupConnectionPromise();
|
|
15888
|
+
const wsURL = this._buildUrl();
|
|
15889
|
+
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15890
|
+
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15891
|
+
this.ws = new WS(wsURL);
|
|
15892
|
+
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15893
|
+
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15894
|
+
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15895
|
+
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15896
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
15897
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
15898
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
15899
|
+
const timers = getTimers();
|
|
15900
|
+
let handshakeTimeoutId;
|
|
15901
|
+
let response;
|
|
15902
|
+
try {
|
|
15903
|
+
response = await Promise.race([
|
|
15904
|
+
this.connectionOpen,
|
|
15905
|
+
new Promise((_, reject) => {
|
|
15906
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
15907
|
+
const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
|
|
15908
|
+
err.isWSFailure = true;
|
|
15909
|
+
reject(err);
|
|
15910
|
+
}, handshakeTimeout);
|
|
15911
|
+
}),
|
|
15912
|
+
]);
|
|
15913
|
+
}
|
|
15914
|
+
finally {
|
|
15915
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
15916
|
+
}
|
|
15917
|
+
this.isConnecting = false;
|
|
15918
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
15919
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
15920
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
15921
|
+
// a misleading "success" for a connection that has already been
|
|
15922
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
15923
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
15924
|
+
// which would observe the already-resolved connectionOpen promise
|
|
15925
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
15926
|
+
if (this.isDisconnected) {
|
|
15927
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15928
|
+
this._destroyCurrentWSConnection();
|
|
15929
|
+
}
|
|
15930
|
+
throw new Error('WS handshake aborted: disconnect() ran while connecting');
|
|
15931
|
+
}
|
|
15932
|
+
if (response) {
|
|
15933
|
+
this.connectionID = response.connection_id;
|
|
15934
|
+
this.client.resolveConnectionId?.(this.connectionID);
|
|
15935
|
+
return response;
|
|
15936
|
+
}
|
|
15937
|
+
}
|
|
15938
|
+
catch (caught) {
|
|
15939
|
+
const err = caught;
|
|
15940
|
+
this.isConnecting = false;
|
|
15941
|
+
this._log(`_connect() - Error - `, err);
|
|
15942
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
15943
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
15944
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
15945
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
15946
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
15947
|
+
// therefore fail fast instead of being orphaned.
|
|
15948
|
+
ownRejectConnectionId?.(err);
|
|
15949
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
15950
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
15951
|
+
this.rejectConnectionOpen?.(err);
|
|
15952
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
15953
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
15954
|
+
this._destroyCurrentWSConnection();
|
|
15955
|
+
}
|
|
15956
|
+
throw err;
|
|
15957
|
+
}
|
|
15958
|
+
};
|
|
14938
15959
|
/**
|
|
14939
15960
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
14940
15961
|
*
|
|
14941
15962
|
* @param {Event} event Event with type online or offline
|
|
14942
|
-
*
|
|
14943
15963
|
*/
|
|
14944
15964
|
this.onlineStatusChanged = (event) => {
|
|
14945
15965
|
if (event.type === 'offline') {
|
|
@@ -15037,16 +16057,12 @@ class StableWSConnection {
|
|
|
15037
16057
|
return;
|
|
15038
16058
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
15039
16059
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
15040
|
-
// this is a permanent error raised by stream
|
|
16060
|
+
// this is a permanent error raised by stream.
|
|
15041
16061
|
// usually caused by invalid auth details
|
|
15042
16062
|
const error = new Error(`WS connection reject with error ${event.reason}`);
|
|
15043
|
-
// @ts-expect-error type issue
|
|
15044
16063
|
error.reason = event.reason;
|
|
15045
|
-
// @ts-expect-error type issue
|
|
15046
16064
|
error.code = event.code;
|
|
15047
|
-
// @ts-expect-error type issue
|
|
15048
16065
|
error.wasClean = event.wasClean;
|
|
15049
|
-
// @ts-expect-error type issue
|
|
15050
16066
|
error.target = event.target;
|
|
15051
16067
|
this.rejectConnectionOpen?.(error);
|
|
15052
16068
|
this._log(`onclose() - WS connection reject with error ${event.reason}`, {
|
|
@@ -15184,205 +16200,8 @@ class StableWSConnection {
|
|
|
15184
16200
|
}, this.connectionCheckTimeout);
|
|
15185
16201
|
};
|
|
15186
16202
|
this.client = client;
|
|
15187
|
-
/** consecutive failures influence the duration of the timeout */
|
|
15188
|
-
this.consecutiveFailures = 0;
|
|
15189
|
-
/** keep track of the total number of failures */
|
|
15190
|
-
this.totalFailures = 0;
|
|
15191
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15192
|
-
this.isConnecting = false;
|
|
15193
|
-
/** To avoid reconnect if client is disconnected */
|
|
15194
|
-
this.isDisconnected = false;
|
|
15195
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
15196
|
-
this.isConnectionOpenResolved = false;
|
|
15197
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
15198
|
-
this.isHealthy = false;
|
|
15199
|
-
/** Incremented when a new WS connection is made */
|
|
15200
|
-
this.wsID = 1;
|
|
15201
|
-
/** Store the last event time for health checks */
|
|
15202
|
-
this.lastEvent = null;
|
|
15203
|
-
/** Send a health check message every 25 seconds */
|
|
15204
|
-
this.pingInterval = 25 * 1000;
|
|
15205
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15206
16203
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
15207
16204
|
}
|
|
15208
|
-
/**
|
|
15209
|
-
* connect - Connect to the WS URL
|
|
15210
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15211
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15212
|
-
*/
|
|
15213
|
-
async connect(timeout = 15000) {
|
|
15214
|
-
if (this.isConnecting) {
|
|
15215
|
-
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15216
|
-
}
|
|
15217
|
-
this.isDisconnected = false;
|
|
15218
|
-
try {
|
|
15219
|
-
const healthCheck = await this._connect();
|
|
15220
|
-
this.consecutiveFailures = 0;
|
|
15221
|
-
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15222
|
-
}
|
|
15223
|
-
catch (error) {
|
|
15224
|
-
this.isHealthy = false;
|
|
15225
|
-
this.consecutiveFailures += 1;
|
|
15226
|
-
if (
|
|
15227
|
-
// @ts-expect-error type issue
|
|
15228
|
-
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15229
|
-
!this.client.tokenManager.isStatic()) {
|
|
15230
|
-
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15231
|
-
this._reconnect({ refreshToken: true });
|
|
15232
|
-
}
|
|
15233
|
-
else {
|
|
15234
|
-
// @ts-expect-error type issue
|
|
15235
|
-
if (!error.isWSFailure) {
|
|
15236
|
-
// API rejected the connection and we should not retry
|
|
15237
|
-
throw new Error(JSON.stringify({
|
|
15238
|
-
// @ts-expect-error type issue
|
|
15239
|
-
code: error.code,
|
|
15240
|
-
// @ts-expect-error type issue
|
|
15241
|
-
StatusCode: error.StatusCode,
|
|
15242
|
-
// @ts-expect-error type issue
|
|
15243
|
-
message: error.message,
|
|
15244
|
-
// @ts-expect-error type issue
|
|
15245
|
-
isWSFailure: error.isWSFailure,
|
|
15246
|
-
}));
|
|
15247
|
-
}
|
|
15248
|
-
}
|
|
15249
|
-
}
|
|
15250
|
-
return await this._waitForHealthy(timeout);
|
|
15251
|
-
}
|
|
15252
|
-
/**
|
|
15253
|
-
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15254
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15255
|
-
* @param timeout duration(ms)
|
|
15256
|
-
*/
|
|
15257
|
-
async _waitForHealthy(timeout = 15000) {
|
|
15258
|
-
return Promise.race([
|
|
15259
|
-
(async () => {
|
|
15260
|
-
const interval = 50; // ms
|
|
15261
|
-
for (let i = 0; i <= timeout; i += interval) {
|
|
15262
|
-
try {
|
|
15263
|
-
return await this.connectionOpen;
|
|
15264
|
-
}
|
|
15265
|
-
catch (error) {
|
|
15266
|
-
if (i === timeout) {
|
|
15267
|
-
throw new Error(JSON.stringify({
|
|
15268
|
-
code: error.code,
|
|
15269
|
-
StatusCode: error.StatusCode,
|
|
15270
|
-
message: error.message,
|
|
15271
|
-
isWSFailure: error.isWSFailure,
|
|
15272
|
-
}));
|
|
15273
|
-
}
|
|
15274
|
-
await sleep(interval);
|
|
15275
|
-
}
|
|
15276
|
-
}
|
|
15277
|
-
})(),
|
|
15278
|
-
(async () => {
|
|
15279
|
-
await sleep(timeout);
|
|
15280
|
-
this.isConnecting = false;
|
|
15281
|
-
throw new Error(JSON.stringify({
|
|
15282
|
-
code: '',
|
|
15283
|
-
StatusCode: '',
|
|
15284
|
-
message: 'initial WS connection could not be established',
|
|
15285
|
-
isWSFailure: true,
|
|
15286
|
-
}));
|
|
15287
|
-
})(),
|
|
15288
|
-
]);
|
|
15289
|
-
}
|
|
15290
|
-
/**
|
|
15291
|
-
* disconnect - Disconnect the connection and doesn't recover...
|
|
15292
|
-
*
|
|
15293
|
-
*/
|
|
15294
|
-
disconnect(timeout) {
|
|
15295
|
-
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15296
|
-
this.wsID += 1;
|
|
15297
|
-
this.isConnecting = false;
|
|
15298
|
-
this.isDisconnected = true;
|
|
15299
|
-
// start by removing all the listeners
|
|
15300
|
-
if (this.healthCheckTimeoutRef) {
|
|
15301
|
-
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15302
|
-
}
|
|
15303
|
-
if (this.connectionCheckTimeoutRef) {
|
|
15304
|
-
clearInterval(this.connectionCheckTimeoutRef);
|
|
15305
|
-
}
|
|
15306
|
-
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15307
|
-
this.isHealthy = false;
|
|
15308
|
-
let isClosedPromise;
|
|
15309
|
-
// and finally close...
|
|
15310
|
-
// Assigning to local here because we will remove it from this before the
|
|
15311
|
-
// promise resolves.
|
|
15312
|
-
const { ws } = this;
|
|
15313
|
-
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15314
|
-
isClosedPromise = new Promise((resolve) => {
|
|
15315
|
-
const onclose = (event) => {
|
|
15316
|
-
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15317
|
-
resolve();
|
|
15318
|
-
};
|
|
15319
|
-
ws.onclose = onclose;
|
|
15320
|
-
// In case we don't receive close frame websocket server in time,
|
|
15321
|
-
// lets not wait for more than 1 second.
|
|
15322
|
-
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15323
|
-
});
|
|
15324
|
-
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15325
|
-
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15326
|
-
}
|
|
15327
|
-
else {
|
|
15328
|
-
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15329
|
-
isClosedPromise = Promise.resolve();
|
|
15330
|
-
}
|
|
15331
|
-
delete this.ws;
|
|
15332
|
-
return isClosedPromise;
|
|
15333
|
-
}
|
|
15334
|
-
/**
|
|
15335
|
-
* _connect - Connect to the WS endpoint
|
|
15336
|
-
*
|
|
15337
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15338
|
-
*/
|
|
15339
|
-
async _connect() {
|
|
15340
|
-
if (this.isConnecting)
|
|
15341
|
-
return; // ignore _connect if it's currently trying to connect
|
|
15342
|
-
this.isConnecting = true;
|
|
15343
|
-
let isTokenReady = false;
|
|
15344
|
-
try {
|
|
15345
|
-
this._log(`_connect() - waiting for token`);
|
|
15346
|
-
await this.client.tokenManager.tokenReady();
|
|
15347
|
-
isTokenReady = true;
|
|
15348
|
-
}
|
|
15349
|
-
catch {
|
|
15350
|
-
// token provider has failed before, so try again
|
|
15351
|
-
}
|
|
15352
|
-
try {
|
|
15353
|
-
if (!isTokenReady) {
|
|
15354
|
-
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15355
|
-
await this.client.tokenManager.loadToken();
|
|
15356
|
-
}
|
|
15357
|
-
if (!this.client.isConnectionIsPromisePending) {
|
|
15358
|
-
this.client._setupConnectionIdPromise();
|
|
15359
|
-
}
|
|
15360
|
-
this._setupConnectionPromise();
|
|
15361
|
-
const wsURL = this._buildUrl();
|
|
15362
|
-
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15363
|
-
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15364
|
-
this.ws = new WS(wsURL);
|
|
15365
|
-
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15366
|
-
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15367
|
-
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15368
|
-
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15369
|
-
const response = await this.connectionOpen;
|
|
15370
|
-
this.isConnecting = false;
|
|
15371
|
-
if (response) {
|
|
15372
|
-
this.connectionID = response.connection_id;
|
|
15373
|
-
this.client.resolveConnectionId?.(this.connectionID);
|
|
15374
|
-
return response;
|
|
15375
|
-
}
|
|
15376
|
-
}
|
|
15377
|
-
catch (err) {
|
|
15378
|
-
this.client._setupConnectionIdPromise();
|
|
15379
|
-
this.isConnecting = false;
|
|
15380
|
-
// @ts-expect-error type issue
|
|
15381
|
-
this._log(`_connect() - Error - `, err);
|
|
15382
|
-
this.client.rejectConnectionId?.(err);
|
|
15383
|
-
throw err;
|
|
15384
|
-
}
|
|
15385
|
-
}
|
|
15386
16205
|
/**
|
|
15387
16206
|
* _reconnect - Retry the connection to WS endpoint
|
|
15388
16207
|
*
|
|
@@ -15429,7 +16248,8 @@ class StableWSConnection {
|
|
|
15429
16248
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
15430
16249
|
this.consecutiveFailures = 0;
|
|
15431
16250
|
}
|
|
15432
|
-
catch (
|
|
16251
|
+
catch (caught) {
|
|
16252
|
+
const error = caught;
|
|
15433
16253
|
this.isHealthy = false;
|
|
15434
16254
|
this.consecutiveFailures += 1;
|
|
15435
16255
|
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
@@ -15986,7 +16806,7 @@ class StreamClient {
|
|
|
15986
16806
|
this.getUserAgent = () => {
|
|
15987
16807
|
if (!this.cachedUserAgent) {
|
|
15988
16808
|
const { clientAppIdentifier = {} } = this.options;
|
|
15989
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
16809
|
+
const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
|
|
15990
16810
|
this.cachedUserAgent = [
|
|
15991
16811
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
15992
16812
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16094,7 +16914,7 @@ class StreamClient {
|
|
|
16094
16914
|
get connectionIdPromise() {
|
|
16095
16915
|
return this.connectionIdPromiseSafe?.();
|
|
16096
16916
|
}
|
|
16097
|
-
get
|
|
16917
|
+
get isConnectionIdPromisePending() {
|
|
16098
16918
|
return this.connectionIdPromiseSafe?.checkPending() ?? false;
|
|
16099
16919
|
}
|
|
16100
16920
|
get wsPromise() {
|