@stream-io/video-client 1.49.0 → 1.51.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 +22 -0
- package/dist/index.browser.es.js +1404 -682
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1404 -682
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1404 -682
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +23 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +38 -3
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +111 -33
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +239 -39
- package/src/devices/DeviceManagerState.ts +4 -2
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +404 -1
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +185 -40
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +747 -88
- package/src/rtc/__tests__/Subscriber.test.ts +148 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
- package/src/rtc/helpers/degradationPreference.ts +40 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
package/dist/index.cjs.js
CHANGED
|
@@ -1418,6 +1418,35 @@ var ClientCapability;
|
|
|
1418
1418
|
*/
|
|
1419
1419
|
ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
|
|
1420
1420
|
})(ClientCapability || (ClientCapability = {}));
|
|
1421
|
+
/**
|
|
1422
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1423
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1424
|
+
*
|
|
1425
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1426
|
+
*/
|
|
1427
|
+
var DegradationPreference;
|
|
1428
|
+
(function (DegradationPreference) {
|
|
1429
|
+
/**
|
|
1430
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1431
|
+
*/
|
|
1432
|
+
DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
1433
|
+
/**
|
|
1434
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1435
|
+
*/
|
|
1436
|
+
DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
|
|
1437
|
+
/**
|
|
1438
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1439
|
+
*/
|
|
1440
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
|
|
1441
|
+
/**
|
|
1442
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1443
|
+
*/
|
|
1444
|
+
DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
|
|
1445
|
+
/**
|
|
1446
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1447
|
+
*/
|
|
1448
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
|
|
1449
|
+
})(DegradationPreference || (DegradationPreference = {}));
|
|
1421
1450
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1422
1451
|
class CallState$Type extends runtime.MessageType {
|
|
1423
1452
|
constructor() {
|
|
@@ -1687,6 +1716,16 @@ class PublishOption$Type extends runtime.MessageType {
|
|
|
1687
1716
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1688
1717
|
T: () => AudioBitrate,
|
|
1689
1718
|
},
|
|
1719
|
+
{
|
|
1720
|
+
no: 11,
|
|
1721
|
+
name: 'degradation_preference',
|
|
1722
|
+
kind: 'enum',
|
|
1723
|
+
T: () => [
|
|
1724
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1725
|
+
DegradationPreference,
|
|
1726
|
+
'DEGRADATION_PREFERENCE_',
|
|
1727
|
+
],
|
|
1728
|
+
},
|
|
1690
1729
|
]);
|
|
1691
1730
|
}
|
|
1692
1731
|
}
|
|
@@ -2133,6 +2172,7 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
2133
2172
|
ClientDetails: ClientDetails,
|
|
2134
2173
|
Codec: Codec,
|
|
2135
2174
|
get ConnectionQuality () { return ConnectionQuality; },
|
|
2175
|
+
get DegradationPreference () { return DegradationPreference; },
|
|
2136
2176
|
Device: Device,
|
|
2137
2177
|
Error: Error$2,
|
|
2138
2178
|
get ErrorCode () { return ErrorCode; },
|
|
@@ -3520,6 +3560,16 @@ class VideoSender$Type extends runtime.MessageType {
|
|
|
3520
3560
|
kind: 'scalar',
|
|
3521
3561
|
T: 5 /*ScalarType.INT32*/,
|
|
3522
3562
|
},
|
|
3563
|
+
{
|
|
3564
|
+
no: 6,
|
|
3565
|
+
name: 'degradation_preference',
|
|
3566
|
+
kind: 'enum',
|
|
3567
|
+
T: () => [
|
|
3568
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
3569
|
+
DegradationPreference,
|
|
3570
|
+
'DEGRADATION_PREFERENCE_',
|
|
3571
|
+
],
|
|
3572
|
+
},
|
|
3523
3573
|
]);
|
|
3524
3574
|
}
|
|
3525
3575
|
}
|
|
@@ -3885,6 +3935,18 @@ const createSignalClient = (options) => {
|
|
|
3885
3935
|
};
|
|
3886
3936
|
|
|
3887
3937
|
const sleep = (m) => new Promise((r) => setTimeout(r, m));
|
|
3938
|
+
const timeboxed = async (promises, ms) => {
|
|
3939
|
+
let timerId;
|
|
3940
|
+
const timeout = new Promise((_, reject) => {
|
|
3941
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
3942
|
+
});
|
|
3943
|
+
try {
|
|
3944
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
3945
|
+
}
|
|
3946
|
+
finally {
|
|
3947
|
+
clearTimeout(timerId);
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3888
3950
|
function isFunction(value) {
|
|
3889
3951
|
return (value &&
|
|
3890
3952
|
(Object.prototype.toString.call(value) === '[object Function]' ||
|
|
@@ -4624,6 +4686,20 @@ const setCurrentValue = (subject, update) => {
|
|
|
4624
4686
|
subject.next(next);
|
|
4625
4687
|
return next;
|
|
4626
4688
|
};
|
|
4689
|
+
/**
|
|
4690
|
+
* Updates the value of the provided Subject asynchronously.
|
|
4691
|
+
* Locks the subject to prevent concurrent updates.
|
|
4692
|
+
*
|
|
4693
|
+
* @param subject the subject to update.
|
|
4694
|
+
* @param update the update to apply to the subject.
|
|
4695
|
+
*/
|
|
4696
|
+
const setCurrentValueAsync = async (subject, update) => {
|
|
4697
|
+
return withoutConcurrency(subject, async () => {
|
|
4698
|
+
const next = await update(getCurrentValue(subject));
|
|
4699
|
+
subject.next(next);
|
|
4700
|
+
return next;
|
|
4701
|
+
});
|
|
4702
|
+
};
|
|
4627
4703
|
/**
|
|
4628
4704
|
* Updates the value of the provided Subject and returns the previous value
|
|
4629
4705
|
* and a function to roll back the update.
|
|
@@ -4678,6 +4754,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
|
|
|
4678
4754
|
createSubscription: createSubscription,
|
|
4679
4755
|
getCurrentValue: getCurrentValue,
|
|
4680
4756
|
setCurrentValue: setCurrentValue,
|
|
4757
|
+
setCurrentValueAsync: setCurrentValueAsync,
|
|
4681
4758
|
updateValue: updateValue
|
|
4682
4759
|
});
|
|
4683
4760
|
|
|
@@ -6302,7 +6379,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6302
6379
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6303
6380
|
};
|
|
6304
6381
|
|
|
6305
|
-
const version = "1.
|
|
6382
|
+
const version = "1.51.0";
|
|
6306
6383
|
const [major, minor, patch] = version.split('.');
|
|
6307
6384
|
let sdkInfo = {
|
|
6308
6385
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -6455,6 +6532,31 @@ const isSafari = () => {
|
|
|
6455
6532
|
return false;
|
|
6456
6533
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
6457
6534
|
};
|
|
6535
|
+
/**
|
|
6536
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
6537
|
+
*
|
|
6538
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
6539
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
6540
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
6541
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
6542
|
+
* share the underlying WebKit quirks.
|
|
6543
|
+
*
|
|
6544
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
6545
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
6546
|
+
*/
|
|
6547
|
+
const isWebKit = () => {
|
|
6548
|
+
if (typeof navigator === 'undefined')
|
|
6549
|
+
return false;
|
|
6550
|
+
const ua = navigator.userAgent || '';
|
|
6551
|
+
if (!/AppleWebKit\//.test(ua))
|
|
6552
|
+
return false;
|
|
6553
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
6554
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
6555
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
6556
|
+
// the mobile Blink stack.
|
|
6557
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
6558
|
+
return !regExp.test(ua);
|
|
6559
|
+
};
|
|
6458
6560
|
/**
|
|
6459
6561
|
* Checks whether the current browser is Firefox.
|
|
6460
6562
|
*/
|
|
@@ -6498,7 +6600,8 @@ var browsers = /*#__PURE__*/Object.freeze({
|
|
|
6498
6600
|
isChrome: isChrome,
|
|
6499
6601
|
isFirefox: isFirefox,
|
|
6500
6602
|
isSafari: isSafari,
|
|
6501
|
-
isSupportedBrowser: isSupportedBrowser
|
|
6603
|
+
isSupportedBrowser: isSupportedBrowser,
|
|
6604
|
+
isWebKit: isWebKit
|
|
6502
6605
|
});
|
|
6503
6606
|
|
|
6504
6607
|
/**
|
|
@@ -7395,7 +7498,7 @@ class BasePeerConnection {
|
|
|
7395
7498
|
this.on = (event, fn) => {
|
|
7396
7499
|
const getTag = () => this.tag;
|
|
7397
7500
|
this.subscriptions.push(this.dispatcher.on(event, getTag, (e) => {
|
|
7398
|
-
const lockKey =
|
|
7501
|
+
const lockKey = this.eventLockKey(event);
|
|
7399
7502
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
7400
7503
|
if (this.isDisposed)
|
|
7401
7504
|
return;
|
|
@@ -7403,6 +7506,13 @@ class BasePeerConnection {
|
|
|
7403
7506
|
});
|
|
7404
7507
|
}));
|
|
7405
7508
|
};
|
|
7509
|
+
/**
|
|
7510
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
7511
|
+
* dispatcher handler for `event` on this peer connection.
|
|
7512
|
+
*/
|
|
7513
|
+
this.eventLockKey = (event) => {
|
|
7514
|
+
return `pc.${this.lock}.${event}`;
|
|
7515
|
+
};
|
|
7406
7516
|
/**
|
|
7407
7517
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
7408
7518
|
*/
|
|
@@ -7656,7 +7766,7 @@ class BasePeerConnection {
|
|
|
7656
7766
|
/**
|
|
7657
7767
|
* Disposes the `RTCPeerConnection` instance.
|
|
7658
7768
|
*/
|
|
7659
|
-
dispose() {
|
|
7769
|
+
async dispose() {
|
|
7660
7770
|
clearTimeout(this.iceRestartTimeout);
|
|
7661
7771
|
this.iceRestartTimeout = undefined;
|
|
7662
7772
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -7678,6 +7788,7 @@ class BasePeerConnection {
|
|
|
7678
7788
|
pc.removeEventListener('signalingstatechange', this.onSignalingChange);
|
|
7679
7789
|
pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
7680
7790
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
7791
|
+
pc.removeEventListener('connectionstatechange', this.onConnectionStateChange);
|
|
7681
7792
|
this.unsubscribeIceTrickle?.();
|
|
7682
7793
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
7683
7794
|
this.subscriptions = [];
|
|
@@ -7705,8 +7816,14 @@ class TransceiverCache {
|
|
|
7705
7816
|
* Gets the transceiver for the given publish option.
|
|
7706
7817
|
*/
|
|
7707
7818
|
this.get = (publishOption) => {
|
|
7708
|
-
return this.
|
|
7709
|
-
|
|
7819
|
+
return this.getBy(publishOption.id, publishOption.trackType);
|
|
7820
|
+
};
|
|
7821
|
+
/**
|
|
7822
|
+
* Gets the transceiver for the given publish option id and track type.
|
|
7823
|
+
*/
|
|
7824
|
+
this.getBy = (publishOptionId, trackType) => {
|
|
7825
|
+
return this.cache.find((bundle) => bundle.publishOption.id === publishOptionId &&
|
|
7826
|
+
bundle.publishOption.trackType === trackType);
|
|
7710
7827
|
};
|
|
7711
7828
|
/**
|
|
7712
7829
|
* Updates the cached bundle with the given patch.
|
|
@@ -7974,6 +8091,39 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
|
|
|
7974
8091
|
}));
|
|
7975
8092
|
};
|
|
7976
8093
|
|
|
8094
|
+
const toRTCDegradationPreference = (preference) => {
|
|
8095
|
+
switch (preference) {
|
|
8096
|
+
case DegradationPreference.BALANCED:
|
|
8097
|
+
return 'balanced';
|
|
8098
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
8099
|
+
return 'maintain-framerate';
|
|
8100
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
8101
|
+
return 'maintain-resolution';
|
|
8102
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
8103
|
+
// @ts-expect-error not in the typedefs yet
|
|
8104
|
+
return 'maintain-framerate-and-resolution';
|
|
8105
|
+
case DegradationPreference.UNSPECIFIED:
|
|
8106
|
+
return undefined;
|
|
8107
|
+
default:
|
|
8108
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8109
|
+
}
|
|
8110
|
+
};
|
|
8111
|
+
const fromRTCDegradationPreference = (preference) => {
|
|
8112
|
+
switch (preference) {
|
|
8113
|
+
case 'balanced':
|
|
8114
|
+
return DegradationPreference.BALANCED;
|
|
8115
|
+
case 'maintain-framerate':
|
|
8116
|
+
return DegradationPreference.MAINTAIN_FRAMERATE;
|
|
8117
|
+
case 'maintain-resolution':
|
|
8118
|
+
return DegradationPreference.MAINTAIN_RESOLUTION;
|
|
8119
|
+
// @ts-expect-error not in the typedefs yet
|
|
8120
|
+
case 'maintain-framerate-and-resolution':
|
|
8121
|
+
return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
|
|
8122
|
+
default:
|
|
8123
|
+
return DegradationPreference.UNSPECIFIED;
|
|
8124
|
+
}
|
|
8125
|
+
};
|
|
8126
|
+
|
|
7977
8127
|
/**
|
|
7978
8128
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
7979
8129
|
*
|
|
@@ -8007,13 +8157,13 @@ class Publisher extends BasePeerConnection {
|
|
|
8007
8157
|
// create a clone of the track as otherwise the same trackId will
|
|
8008
8158
|
// appear in the SDP in multiple transceivers
|
|
8009
8159
|
const trackToPublish = this.cloneTrack(track);
|
|
8010
|
-
const
|
|
8011
|
-
if (!
|
|
8160
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
8161
|
+
if (!bundle) {
|
|
8012
8162
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
8013
8163
|
}
|
|
8014
8164
|
else {
|
|
8015
|
-
const previousTrack = transceiver.sender.track;
|
|
8016
|
-
await this.updateTransceiver(
|
|
8165
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
8166
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
8017
8167
|
if (!isReactNative()) {
|
|
8018
8168
|
this.stopTrack(previousTrack);
|
|
8019
8169
|
}
|
|
@@ -8035,7 +8185,9 @@ class Publisher extends BasePeerConnection {
|
|
|
8035
8185
|
sendEncodings,
|
|
8036
8186
|
});
|
|
8037
8187
|
const params = transceiver.sender.getParameters();
|
|
8038
|
-
params.degradationPreference =
|
|
8188
|
+
params.degradationPreference =
|
|
8189
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
8190
|
+
'maintain-framerate';
|
|
8039
8191
|
await transceiver.sender.setParameters(params);
|
|
8040
8192
|
const trackType = publishOption.trackType;
|
|
8041
8193
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
@@ -8046,13 +8198,20 @@ class Publisher extends BasePeerConnection {
|
|
|
8046
8198
|
/**
|
|
8047
8199
|
* Updates the transceiver with the given track and track type.
|
|
8048
8200
|
*/
|
|
8049
|
-
this.updateTransceiver = async (
|
|
8201
|
+
this.updateTransceiver = async (bundle, track, options = {}) => {
|
|
8202
|
+
const { transceiver, publishOption } = bundle;
|
|
8203
|
+
const trackType = publishOption.trackType;
|
|
8050
8204
|
const sender = transceiver.sender;
|
|
8051
8205
|
if (sender.track)
|
|
8052
8206
|
this.trackIdToTrackType.delete(sender.track.id);
|
|
8053
8207
|
await sender.replaceTrack(track);
|
|
8054
|
-
if (track)
|
|
8208
|
+
if (track) {
|
|
8055
8209
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
8210
|
+
if (isFirefox() && bundle.videoSender) {
|
|
8211
|
+
// restore the encoding config from the cache, if any
|
|
8212
|
+
await this.changePublishQuality(bundle.videoSender, bundle);
|
|
8213
|
+
}
|
|
8214
|
+
}
|
|
8056
8215
|
if (isAudioTrackType(trackType)) {
|
|
8057
8216
|
await this.updateAudioPublishOptions(trackType, options);
|
|
8058
8217
|
}
|
|
@@ -8112,7 +8271,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8112
8271
|
continue;
|
|
8113
8272
|
// it is safe to stop the track here, it is a clone
|
|
8114
8273
|
this.stopTrack(transceiver.sender.track);
|
|
8115
|
-
await this.updateTransceiver(
|
|
8274
|
+
await this.updateTransceiver(item, null);
|
|
8116
8275
|
}
|
|
8117
8276
|
};
|
|
8118
8277
|
/**
|
|
@@ -8133,35 +8292,74 @@ class Publisher extends BasePeerConnection {
|
|
|
8133
8292
|
return false;
|
|
8134
8293
|
};
|
|
8135
8294
|
/**
|
|
8136
|
-
*
|
|
8295
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
8296
|
+
* reattaching the currently published track on each matching sender.
|
|
8297
|
+
*
|
|
8298
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
8299
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
8300
|
+
* can stop producing RTP packets even though the underlying
|
|
8301
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
8302
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
8303
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
8304
|
+
* flow with the same SSRC.
|
|
8305
|
+
*
|
|
8306
|
+
* No-op when nothing is published for the given track type.
|
|
8307
|
+
*
|
|
8308
|
+
* @param trackType the track type to refresh.
|
|
8137
8309
|
*/
|
|
8138
|
-
this.
|
|
8310
|
+
this.refreshTrack = async (trackType) => {
|
|
8139
8311
|
for (const item of this.transceiverCache.items()) {
|
|
8140
|
-
|
|
8141
|
-
if (!trackTypes.includes(publishOption.trackType))
|
|
8312
|
+
if (item.publishOption.trackType !== trackType)
|
|
8142
8313
|
continue;
|
|
8143
|
-
|
|
8314
|
+
const { sender } = item.transceiver;
|
|
8315
|
+
const track = sender.track;
|
|
8316
|
+
if (!track || track.readyState !== 'live')
|
|
8317
|
+
continue;
|
|
8318
|
+
try {
|
|
8319
|
+
await sender.replaceTrack(null);
|
|
8320
|
+
await sender.replaceTrack(track);
|
|
8321
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
8322
|
+
}
|
|
8323
|
+
catch (err) {
|
|
8324
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
8325
|
+
}
|
|
8144
8326
|
}
|
|
8145
8327
|
};
|
|
8328
|
+
/**
|
|
8329
|
+
* Stops the cloned track that is being published to the SFU.
|
|
8330
|
+
*/
|
|
8331
|
+
this.stopTracks = async (...trackTypes) => {
|
|
8332
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8333
|
+
for (const item of this.transceiverCache.items()) {
|
|
8334
|
+
const { publishOption, transceiver } = item;
|
|
8335
|
+
if (!trackTypes.includes(publishOption.trackType))
|
|
8336
|
+
continue;
|
|
8337
|
+
const track = transceiver.sender.track;
|
|
8338
|
+
await this.silenceSenderOnFirefox(item);
|
|
8339
|
+
this.stopTrack(track);
|
|
8340
|
+
}
|
|
8341
|
+
});
|
|
8342
|
+
};
|
|
8146
8343
|
/**
|
|
8147
8344
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
8148
8345
|
*/
|
|
8149
|
-
this.stopAllTracks = () => {
|
|
8150
|
-
|
|
8151
|
-
this.
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8346
|
+
this.stopAllTracks = async () => {
|
|
8347
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8348
|
+
for (const item of this.transceiverCache.items()) {
|
|
8349
|
+
const track = item.transceiver.sender.track;
|
|
8350
|
+
await this.silenceSenderOnFirefox(item);
|
|
8351
|
+
this.stopTrack(track);
|
|
8352
|
+
}
|
|
8353
|
+
for (const track of this.clonedTracks) {
|
|
8354
|
+
this.stopTrack(track);
|
|
8355
|
+
}
|
|
8356
|
+
});
|
|
8156
8357
|
};
|
|
8157
|
-
this.changePublishQuality = async (videoSender) => {
|
|
8158
|
-
const
|
|
8159
|
-
const enabledLayers = layers.filter((l) => l.active);
|
|
8358
|
+
this.changePublishQuality = async (videoSender, bundle) => {
|
|
8359
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
8160
8360
|
const tag = 'Update publish quality:';
|
|
8161
8361
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
8162
|
-
const
|
|
8163
|
-
t.publishOption.trackType === trackType);
|
|
8164
|
-
const sender = transceiverId?.transceiver.sender;
|
|
8362
|
+
const sender = bundle?.transceiver.sender;
|
|
8165
8363
|
if (!sender) {
|
|
8166
8364
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
8167
8365
|
}
|
|
@@ -8169,7 +8367,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8169
8367
|
if (params.encodings.length === 0) {
|
|
8170
8368
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
8171
8369
|
}
|
|
8172
|
-
const codecInUse =
|
|
8370
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
8173
8371
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
8174
8372
|
let changed = false;
|
|
8175
8373
|
for (const encoder of params.encodings) {
|
|
@@ -8209,6 +8407,12 @@ class Publisher extends BasePeerConnection {
|
|
|
8209
8407
|
changed = true;
|
|
8210
8408
|
}
|
|
8211
8409
|
}
|
|
8410
|
+
const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
|
|
8411
|
+
if (degradationPreference &&
|
|
8412
|
+
params.degradationPreference !== degradationPreference) {
|
|
8413
|
+
params.degradationPreference = degradationPreference;
|
|
8414
|
+
changed = true;
|
|
8415
|
+
}
|
|
8212
8416
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
8213
8417
|
if (!changed) {
|
|
8214
8418
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -8363,6 +8567,72 @@ class Publisher extends BasePeerConnection {
|
|
|
8363
8567
|
track.stop();
|
|
8364
8568
|
this.clonedTracks.delete(track);
|
|
8365
8569
|
};
|
|
8570
|
+
/**
|
|
8571
|
+
* Silences a Firefox sender on the wire during unpublish.
|
|
8572
|
+
*
|
|
8573
|
+
* Firefox keeps emitting RTP after track.stop(), but the right lever
|
|
8574
|
+
* differs by track type:
|
|
8575
|
+
* - audio: `replaceTrack(null)` is the only reliable silencer;
|
|
8576
|
+
* `setParameters({encodings:[...active:false]})` does NOT stop
|
|
8577
|
+
* the Opus encoder.
|
|
8578
|
+
* - video: `setParameters({encodings:[...active:false]})` pauses
|
|
8579
|
+
* the encoder; `replaceTrack(null)` does NOT reliably stop the
|
|
8580
|
+
* video encoder. The prior active=true configuration is captured
|
|
8581
|
+
* onto `bundle.videoSender` so `updateTransceiver` can restore
|
|
8582
|
+
* it on the next publish.
|
|
8583
|
+
*
|
|
8584
|
+
* No-op on non-Firefox browsers and during teardown.
|
|
8585
|
+
*/
|
|
8586
|
+
this.silenceSenderOnFirefox = async (bundle) => {
|
|
8587
|
+
if (this.isDisposed || !isFirefox())
|
|
8588
|
+
return;
|
|
8589
|
+
const { transceiver, publishOption } = bundle;
|
|
8590
|
+
if (isAudioTrackType(publishOption.trackType)) {
|
|
8591
|
+
await transceiver.sender.replaceTrack(null).catch((err) => {
|
|
8592
|
+
this.logger.warn('Failed to clear audio sender track', err);
|
|
8593
|
+
});
|
|
8594
|
+
return;
|
|
8595
|
+
}
|
|
8596
|
+
await this.disableAllEncodings(bundle);
|
|
8597
|
+
};
|
|
8598
|
+
this.disableAllEncodings = async (bundle) => {
|
|
8599
|
+
const { transceiver, publishOption } = bundle;
|
|
8600
|
+
const sender = transceiver.sender;
|
|
8601
|
+
const params = sender.getParameters();
|
|
8602
|
+
if (!params.encodings || params.encodings.length === 0)
|
|
8603
|
+
return;
|
|
8604
|
+
if (!bundle.videoSender) {
|
|
8605
|
+
this.transceiverCache.update(publishOption, {
|
|
8606
|
+
videoSender: {
|
|
8607
|
+
trackType: publishOption.trackType,
|
|
8608
|
+
publishOptionId: publishOption.id,
|
|
8609
|
+
codec: publishOption.codec,
|
|
8610
|
+
degradationPreference: fromRTCDegradationPreference(params.degradationPreference),
|
|
8611
|
+
layers: params.encodings.map((e) => ({
|
|
8612
|
+
name: e.rid ?? 'q',
|
|
8613
|
+
active: e.active ?? true,
|
|
8614
|
+
maxBitrate: e.maxBitrate ?? 0,
|
|
8615
|
+
scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
|
|
8616
|
+
maxFramerate: e.maxFramerate ?? 0,
|
|
8617
|
+
// @ts-expect-error scalabilityMode is not in the typedefs yet
|
|
8618
|
+
scalabilityMode: e.scalabilityMode ?? '',
|
|
8619
|
+
})),
|
|
8620
|
+
},
|
|
8621
|
+
});
|
|
8622
|
+
}
|
|
8623
|
+
let changed = false;
|
|
8624
|
+
for (const encoding of params.encodings) {
|
|
8625
|
+
if (encoding.active !== false) {
|
|
8626
|
+
encoding.active = false;
|
|
8627
|
+
changed = true;
|
|
8628
|
+
}
|
|
8629
|
+
}
|
|
8630
|
+
if (!changed)
|
|
8631
|
+
return;
|
|
8632
|
+
await sender.setParameters(params).catch((err) => {
|
|
8633
|
+
this.logger.error('Failed to disable video sender encodings:', err);
|
|
8634
|
+
});
|
|
8635
|
+
};
|
|
8366
8636
|
this.publishOptions = publishOptions;
|
|
8367
8637
|
this.on('iceRestart', (iceRestart) => {
|
|
8368
8638
|
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
|
|
@@ -8371,7 +8641,16 @@ class Publisher extends BasePeerConnection {
|
|
|
8371
8641
|
});
|
|
8372
8642
|
this.on('changePublishQuality', async (event) => {
|
|
8373
8643
|
for (const videoSender of event.videoSenders) {
|
|
8374
|
-
|
|
8644
|
+
// if not publishing, update the encodingConfigCache and don't modify the state.
|
|
8645
|
+
// we'll apply this config on the next publish/unmute.
|
|
8646
|
+
const { trackType, publishOptionId } = videoSender;
|
|
8647
|
+
const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
|
|
8648
|
+
if (bundle) {
|
|
8649
|
+
this.transceiverCache.update(bundle.publishOption, { videoSender });
|
|
8650
|
+
}
|
|
8651
|
+
if (isFirefox() && !this.isPublishing(trackType))
|
|
8652
|
+
continue;
|
|
8653
|
+
await this.changePublishQuality(videoSender, bundle);
|
|
8375
8654
|
}
|
|
8376
8655
|
});
|
|
8377
8656
|
this.on('changePublishOptions', (event) => {
|
|
@@ -8382,13 +8661,48 @@ class Publisher extends BasePeerConnection {
|
|
|
8382
8661
|
/**
|
|
8383
8662
|
* Disposes this Publisher instance.
|
|
8384
8663
|
*/
|
|
8385
|
-
dispose() {
|
|
8386
|
-
super.dispose();
|
|
8387
|
-
|
|
8664
|
+
async dispose() {
|
|
8665
|
+
await super.dispose();
|
|
8666
|
+
try {
|
|
8667
|
+
await this.stopAllTracks();
|
|
8668
|
+
}
|
|
8669
|
+
catch (err) {
|
|
8670
|
+
this.logger.warn('Failed to stop tracks during dispose', err);
|
|
8671
|
+
}
|
|
8388
8672
|
this.clonedTracks.clear();
|
|
8389
8673
|
}
|
|
8390
8674
|
}
|
|
8391
8675
|
|
|
8676
|
+
/**
|
|
8677
|
+
* Adds unique values to an array.
|
|
8678
|
+
*
|
|
8679
|
+
* @param arr the array to add to.
|
|
8680
|
+
* @param values the values to add.
|
|
8681
|
+
*/
|
|
8682
|
+
const pushToIfMissing = (arr, ...values) => {
|
|
8683
|
+
for (const v of values) {
|
|
8684
|
+
if (!arr.includes(v)) {
|
|
8685
|
+
arr.push(v);
|
|
8686
|
+
}
|
|
8687
|
+
}
|
|
8688
|
+
return arr;
|
|
8689
|
+
};
|
|
8690
|
+
/**
|
|
8691
|
+
* Removes values from an array if they are present.
|
|
8692
|
+
*
|
|
8693
|
+
* @param arr the array to remove from.
|
|
8694
|
+
* @param values the values to remove.
|
|
8695
|
+
*/
|
|
8696
|
+
const removeFromIfPresent = (arr, ...values) => {
|
|
8697
|
+
for (const v of values) {
|
|
8698
|
+
const index = arr.indexOf(v);
|
|
8699
|
+
if (index !== -1) {
|
|
8700
|
+
arr.splice(index, 1);
|
|
8701
|
+
}
|
|
8702
|
+
}
|
|
8703
|
+
return arr;
|
|
8704
|
+
};
|
|
8705
|
+
|
|
8392
8706
|
/**
|
|
8393
8707
|
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
8394
8708
|
* media streams from the SFU.
|
|
@@ -8430,27 +8744,34 @@ class Subscriber extends BasePeerConnection {
|
|
|
8430
8744
|
}
|
|
8431
8745
|
};
|
|
8432
8746
|
this.handleOnTrack = (e) => {
|
|
8433
|
-
const
|
|
8747
|
+
const { streams, track } = e;
|
|
8748
|
+
const [primaryStream] = streams;
|
|
8434
8749
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
8435
8750
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
8436
8751
|
const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8437
|
-
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
8752
|
+
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
|
|
8753
|
+
const trackType = toTrackType(rawTrackType);
|
|
8754
|
+
if (!trackType) {
|
|
8755
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8756
|
+
}
|
|
8438
8757
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
8439
|
-
|
|
8758
|
+
track.addEventListener('mute', () => {
|
|
8440
8759
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
8760
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8441
8761
|
});
|
|
8442
|
-
|
|
8762
|
+
track.addEventListener('unmute', () => {
|
|
8443
8763
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
8764
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8444
8765
|
});
|
|
8445
|
-
|
|
8766
|
+
track.addEventListener('ended', () => {
|
|
8446
8767
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
8768
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8447
8769
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
8448
8770
|
});
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8771
|
+
if (track.muted) {
|
|
8772
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8452
8773
|
}
|
|
8453
|
-
this.trackIdToTrackType.set(
|
|
8774
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
8454
8775
|
if (!participantToUpdate) {
|
|
8455
8776
|
this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
|
|
8456
8777
|
this.state.registerOrphanedTrack({
|
|
@@ -8476,13 +8797,30 @@ class Subscriber extends BasePeerConnection {
|
|
|
8476
8797
|
});
|
|
8477
8798
|
// now, dispose the previous stream if it exists
|
|
8478
8799
|
if (previousStream) {
|
|
8479
|
-
this.logger.info(`[onTrack]: Cleaning up previous remote ${
|
|
8800
|
+
this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
8480
8801
|
previousStream.getTracks().forEach((t) => {
|
|
8481
8802
|
t.stop();
|
|
8482
8803
|
previousStream.removeTrack(t);
|
|
8483
8804
|
});
|
|
8484
8805
|
}
|
|
8485
8806
|
};
|
|
8807
|
+
this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
|
|
8808
|
+
if (trackType !== TrackType.AUDIO)
|
|
8809
|
+
return;
|
|
8810
|
+
const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8811
|
+
if (!target)
|
|
8812
|
+
return;
|
|
8813
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
8814
|
+
const current = p.interruptedTracks ?? [];
|
|
8815
|
+
const has = current.includes(trackType);
|
|
8816
|
+
if (interrupted === has)
|
|
8817
|
+
return {};
|
|
8818
|
+
const next = interrupted
|
|
8819
|
+
? pushToIfMissing([...current], trackType)
|
|
8820
|
+
: removeFromIfPresent([...current], trackType);
|
|
8821
|
+
return { interruptedTracks: next };
|
|
8822
|
+
});
|
|
8823
|
+
};
|
|
8486
8824
|
this.negotiate = async (subscriberOffer) => {
|
|
8487
8825
|
await this.pc.setRemoteDescription({
|
|
8488
8826
|
type: 'offer',
|
|
@@ -9205,36 +9543,6 @@ const watchCallGrantsUpdated = (state) => {
|
|
|
9205
9543
|
};
|
|
9206
9544
|
};
|
|
9207
9545
|
|
|
9208
|
-
/**
|
|
9209
|
-
* Adds unique values to an array.
|
|
9210
|
-
*
|
|
9211
|
-
* @param arr the array to add to.
|
|
9212
|
-
* @param values the values to add.
|
|
9213
|
-
*/
|
|
9214
|
-
const pushToIfMissing = (arr, ...values) => {
|
|
9215
|
-
for (const v of values) {
|
|
9216
|
-
if (!arr.includes(v)) {
|
|
9217
|
-
arr.push(v);
|
|
9218
|
-
}
|
|
9219
|
-
}
|
|
9220
|
-
return arr;
|
|
9221
|
-
};
|
|
9222
|
-
/**
|
|
9223
|
-
* Removes values from an array if they are present.
|
|
9224
|
-
*
|
|
9225
|
-
* @param arr the array to remove from.
|
|
9226
|
-
* @param values the values to remove.
|
|
9227
|
-
*/
|
|
9228
|
-
const removeFromIfPresent = (arr, ...values) => {
|
|
9229
|
-
for (const v of values) {
|
|
9230
|
-
const index = arr.indexOf(v);
|
|
9231
|
-
if (index !== -1) {
|
|
9232
|
-
arr.splice(index, 1);
|
|
9233
|
-
}
|
|
9234
|
-
}
|
|
9235
|
-
return arr;
|
|
9236
|
-
};
|
|
9237
|
-
|
|
9238
9546
|
const watchConnectionQualityChanged = (dispatcher, state) => {
|
|
9239
9547
|
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
9240
9548
|
const { connectionQualityUpdates } = e;
|
|
@@ -9567,140 +9875,54 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
9567
9875
|
};
|
|
9568
9876
|
};
|
|
9569
9877
|
|
|
9570
|
-
const
|
|
9571
|
-
|
|
9572
|
-
|
|
9878
|
+
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9879
|
+
/**
|
|
9880
|
+
* Tracks audio element bindings and periodically warns about
|
|
9881
|
+
* remote participants whose audio streams have no bound element.
|
|
9882
|
+
*/
|
|
9883
|
+
class AudioBindingsWatchdog {
|
|
9884
|
+
constructor(state, tracer) {
|
|
9885
|
+
this.bindings = new Map();
|
|
9886
|
+
this.enabled = true;
|
|
9887
|
+
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9573
9888
|
/**
|
|
9574
|
-
*
|
|
9889
|
+
* Registers an audio element binding for the given session and track type.
|
|
9890
|
+
* Warns if a different element is already bound to the same key.
|
|
9575
9891
|
*/
|
|
9576
|
-
this.
|
|
9892
|
+
this.register = (element, sessionId, trackType) => {
|
|
9893
|
+
const key = toBindingKey(sessionId, trackType);
|
|
9894
|
+
const existing = this.bindings.get(key);
|
|
9895
|
+
if (existing && existing !== element) {
|
|
9896
|
+
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9897
|
+
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9898
|
+
}
|
|
9899
|
+
this.bindings.set(key, element);
|
|
9900
|
+
};
|
|
9577
9901
|
/**
|
|
9578
|
-
*
|
|
9902
|
+
* Removes the audio element binding for the given session and track type.
|
|
9579
9903
|
*/
|
|
9580
|
-
this.
|
|
9581
|
-
|
|
9582
|
-
|
|
9904
|
+
this.unregister = (sessionId, trackType) => {
|
|
9905
|
+
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9906
|
+
};
|
|
9583
9907
|
/**
|
|
9584
|
-
*
|
|
9908
|
+
* Enables or disables the watchdog.
|
|
9909
|
+
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9585
9910
|
*/
|
|
9586
|
-
this.
|
|
9911
|
+
this.setEnabled = (enabled) => {
|
|
9912
|
+
this.enabled = enabled;
|
|
9913
|
+
if (enabled) {
|
|
9914
|
+
this.start();
|
|
9915
|
+
}
|
|
9916
|
+
else {
|
|
9917
|
+
this.stop();
|
|
9918
|
+
}
|
|
9919
|
+
};
|
|
9587
9920
|
/**
|
|
9588
|
-
*
|
|
9589
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9590
|
-
*
|
|
9591
|
-
* @param viewportElement
|
|
9592
|
-
* @param options
|
|
9593
|
-
* @returns Unobserve
|
|
9594
|
-
*/
|
|
9595
|
-
this.setViewport = (viewportElement, options) => {
|
|
9596
|
-
const cleanup = () => {
|
|
9597
|
-
this.observer?.disconnect();
|
|
9598
|
-
this.observer = null;
|
|
9599
|
-
this.elementHandlerMap.clear();
|
|
9600
|
-
};
|
|
9601
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
9602
|
-
entries.forEach((entry) => {
|
|
9603
|
-
const handler = this.elementHandlerMap.get(entry.target);
|
|
9604
|
-
handler?.(entry);
|
|
9605
|
-
});
|
|
9606
|
-
}, {
|
|
9607
|
-
root: viewportElement,
|
|
9608
|
-
...options,
|
|
9609
|
-
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
9610
|
-
});
|
|
9611
|
-
if (this.queueSet.size) {
|
|
9612
|
-
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
9613
|
-
// check if element which requested observation is
|
|
9614
|
-
// a child of a viewport element, skip if isn't
|
|
9615
|
-
if (!viewportElement.contains(queueElement))
|
|
9616
|
-
return;
|
|
9617
|
-
this.observer.observe(queueElement);
|
|
9618
|
-
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
9619
|
-
});
|
|
9620
|
-
this.queueSet.clear();
|
|
9621
|
-
}
|
|
9622
|
-
return cleanup;
|
|
9623
|
-
};
|
|
9624
|
-
/**
|
|
9625
|
-
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
9626
|
-
* detects a possible change in element's visibility within specified viewport, returns
|
|
9627
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9628
|
-
*
|
|
9629
|
-
* @param element
|
|
9630
|
-
* @param handler
|
|
9631
|
-
* @returns Unobserve
|
|
9632
|
-
*/
|
|
9633
|
-
this.observe = (element, handler) => {
|
|
9634
|
-
const queueItem = [element, handler];
|
|
9635
|
-
const cleanup = () => {
|
|
9636
|
-
this.elementHandlerMap.delete(element);
|
|
9637
|
-
this.observer?.unobserve(element);
|
|
9638
|
-
this.queueSet.delete(queueItem);
|
|
9639
|
-
};
|
|
9640
|
-
if (this.elementHandlerMap.has(element))
|
|
9641
|
-
return cleanup;
|
|
9642
|
-
if (!this.observer) {
|
|
9643
|
-
this.queueSet.add(queueItem);
|
|
9644
|
-
return cleanup;
|
|
9645
|
-
}
|
|
9646
|
-
if (this.observer.root.contains(element)) {
|
|
9647
|
-
this.elementHandlerMap.set(element, handler);
|
|
9648
|
-
this.observer.observe(element);
|
|
9649
|
-
}
|
|
9650
|
-
return cleanup;
|
|
9651
|
-
};
|
|
9652
|
-
}
|
|
9653
|
-
}
|
|
9654
|
-
|
|
9655
|
-
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9656
|
-
/**
|
|
9657
|
-
* Tracks audio element bindings and periodically warns about
|
|
9658
|
-
* remote participants whose audio streams have no bound element.
|
|
9659
|
-
*/
|
|
9660
|
-
class AudioBindingsWatchdog {
|
|
9661
|
-
constructor(state, tracer) {
|
|
9662
|
-
this.state = state;
|
|
9663
|
-
this.tracer = tracer;
|
|
9664
|
-
this.bindings = new Map();
|
|
9665
|
-
this.enabled = true;
|
|
9666
|
-
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9667
|
-
/**
|
|
9668
|
-
* Registers an audio element binding for the given session and track type.
|
|
9669
|
-
* Warns if a different element is already bound to the same key.
|
|
9670
|
-
*/
|
|
9671
|
-
this.register = (audioElement, sessionId, trackType) => {
|
|
9672
|
-
const key = toBindingKey(sessionId, trackType);
|
|
9673
|
-
const existing = this.bindings.get(key);
|
|
9674
|
-
if (existing && existing !== audioElement) {
|
|
9675
|
-
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9676
|
-
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9677
|
-
}
|
|
9678
|
-
this.bindings.set(key, audioElement);
|
|
9679
|
-
};
|
|
9680
|
-
/**
|
|
9681
|
-
* Removes the audio element binding for the given session and track type.
|
|
9682
|
-
*/
|
|
9683
|
-
this.unregister = (sessionId, trackType) => {
|
|
9684
|
-
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9685
|
-
};
|
|
9686
|
-
/**
|
|
9687
|
-
* Enables or disables the watchdog.
|
|
9688
|
-
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9689
|
-
*/
|
|
9690
|
-
this.setEnabled = (enabled) => {
|
|
9691
|
-
this.enabled = enabled;
|
|
9692
|
-
if (enabled) {
|
|
9693
|
-
this.start();
|
|
9694
|
-
}
|
|
9695
|
-
else {
|
|
9696
|
-
this.stop();
|
|
9697
|
-
}
|
|
9698
|
-
};
|
|
9699
|
-
/**
|
|
9700
|
-
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9921
|
+
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9701
9922
|
*/
|
|
9702
9923
|
this.dispose = () => {
|
|
9703
9924
|
this.stop();
|
|
9925
|
+
this.bindings.clear();
|
|
9704
9926
|
this.unsubscribeCallingState();
|
|
9705
9927
|
};
|
|
9706
9928
|
this.start = () => {
|
|
@@ -9732,6 +9954,8 @@ class AudioBindingsWatchdog {
|
|
|
9732
9954
|
this.stop = () => {
|
|
9733
9955
|
clearInterval(this.watchdogInterval);
|
|
9734
9956
|
};
|
|
9957
|
+
this.tracer = tracer;
|
|
9958
|
+
this.state = state;
|
|
9735
9959
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
9736
9960
|
if (!this.enabled)
|
|
9737
9961
|
return;
|
|
@@ -9745,61 +9969,97 @@ class AudioBindingsWatchdog {
|
|
|
9745
9969
|
}
|
|
9746
9970
|
}
|
|
9747
9971
|
|
|
9748
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
9749
|
-
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
9750
|
-
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
9751
|
-
};
|
|
9752
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9753
9972
|
/**
|
|
9754
|
-
*
|
|
9755
|
-
*
|
|
9756
|
-
* - binding video elements to session ids
|
|
9757
|
-
* - binding audio elements to session ids
|
|
9758
|
-
* - tracking element visibility
|
|
9759
|
-
* - updating subscriptions based on viewport visibility
|
|
9760
|
-
* - updating subscriptions based on video element dimensions
|
|
9761
|
-
* - updating subscriptions based on published tracks
|
|
9973
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9762
9974
|
*/
|
|
9763
|
-
class
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
constructor(callState, speaker, tracer) {
|
|
9975
|
+
class BlockedAudioTracker {
|
|
9976
|
+
constructor(tracer) {
|
|
9977
|
+
this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
9978
|
+
this.blockedElementsSubject = new rxjs.BehaviorSubject(new Set());
|
|
9768
9979
|
/**
|
|
9769
|
-
*
|
|
9980
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
9981
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
9982
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
9770
9983
|
*/
|
|
9771
|
-
this.
|
|
9772
|
-
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
9773
|
-
this.useWebAudio = false;
|
|
9774
|
-
this.pendingSubscriptionsUpdate = null;
|
|
9984
|
+
this.autoplayBlocked$ = this.blockedElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
|
|
9775
9985
|
/**
|
|
9776
|
-
*
|
|
9777
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
9986
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
9778
9987
|
*/
|
|
9779
|
-
this.
|
|
9780
|
-
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
this.addBlockedAudioElement = (audioElement) => {
|
|
9787
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
9788
|
-
const next = new Set(elements);
|
|
9789
|
-
next.add(audioElement);
|
|
9790
|
-
return next;
|
|
9988
|
+
this.markBlocked = (audioElement, blocked) => {
|
|
9989
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
9990
|
+
if (blocked)
|
|
9991
|
+
elements.add(audioElement);
|
|
9992
|
+
else
|
|
9993
|
+
elements.delete(audioElement);
|
|
9994
|
+
return elements;
|
|
9791
9995
|
});
|
|
9792
9996
|
};
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9997
|
+
/**
|
|
9998
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
9999
|
+
* by the browser's autoplay policy.
|
|
10000
|
+
*/
|
|
10001
|
+
this.isBlocked = (audioElement) => {
|
|
10002
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
10003
|
+
};
|
|
10004
|
+
/**
|
|
10005
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10006
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
10007
|
+
*/
|
|
10008
|
+
this.resumeAudio = async () => {
|
|
10009
|
+
this.tracer.trace('resumeAudio', null);
|
|
10010
|
+
await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
|
|
10011
|
+
await Promise.all(Array.from(elements, async (element) => {
|
|
10012
|
+
try {
|
|
10013
|
+
if (element.srcObject)
|
|
10014
|
+
await timeboxed([element.play()], 2000);
|
|
10015
|
+
elements.delete(element);
|
|
10016
|
+
}
|
|
10017
|
+
catch (err) {
|
|
10018
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
10019
|
+
}
|
|
10020
|
+
}));
|
|
10021
|
+
return elements;
|
|
9798
10022
|
});
|
|
9799
10023
|
};
|
|
9800
|
-
this.
|
|
9801
|
-
|
|
9802
|
-
|
|
10024
|
+
this.tracer = tracer;
|
|
10025
|
+
}
|
|
10026
|
+
}
|
|
10027
|
+
|
|
10028
|
+
/** Symbol key for the "applies to all participants" override slot. */
|
|
10029
|
+
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
10030
|
+
/**
|
|
10031
|
+
* Owns the SFU-side video-subscription machinery for a `Call`:
|
|
10032
|
+
*
|
|
10033
|
+
* - Holds the per-session / global override state in a
|
|
10034
|
+
* `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
|
|
10035
|
+
* - Derives the SFU subscription list from `CallState` participants +
|
|
10036
|
+
* current overrides via the `subscriptions` getter.
|
|
10037
|
+
* - Debounces and pushes the list to the SFU through
|
|
10038
|
+
* `sfuClient.updateSubscriptions`.
|
|
10039
|
+
* - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
|
|
10040
|
+
* the override state for React hooks.
|
|
10041
|
+
*
|
|
10042
|
+
* Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
|
|
10043
|
+
* `DynascaleManager.bindVideoElement` triggers `apply()` on every
|
|
10044
|
+
* dimension / visibility change.
|
|
10045
|
+
*/
|
|
10046
|
+
class TrackSubscriptionManager {
|
|
10047
|
+
/**
|
|
10048
|
+
* Constructs new TrackSubscriptionManager instance.
|
|
10049
|
+
*
|
|
10050
|
+
* @param callState the call state.
|
|
10051
|
+
* @param tracer the tracer to use.
|
|
10052
|
+
*/
|
|
10053
|
+
constructor(callState, tracer) {
|
|
10054
|
+
this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
|
|
10055
|
+
this.pendingUpdate = null;
|
|
10056
|
+
this.overridesSubject = new rxjs.BehaviorSubject({});
|
|
10057
|
+
this.overrides$ = this.overridesSubject.asObservable();
|
|
10058
|
+
/**
|
|
10059
|
+
* Consumer-friendly projection of the override state. Used by the
|
|
10060
|
+
* `useIncomingVideoSettings()` React hook.
|
|
10061
|
+
*/
|
|
10062
|
+
this.incomingVideoSettings$ = this.overrides$.pipe(rxjs.map((overrides) => {
|
|
9803
10063
|
const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
|
|
9804
10064
|
return {
|
|
9805
10065
|
enabled: globalSettings?.enabled !== false,
|
|
@@ -9821,106 +10081,255 @@ class DynascaleManager {
|
|
|
9821
10081
|
};
|
|
9822
10082
|
}), rxjs.shareReplay(1));
|
|
9823
10083
|
/**
|
|
9824
|
-
*
|
|
10084
|
+
* Sets the SFU client used by `apply()` to push subscription updates.
|
|
10085
|
+
* Called by the owner on call join; cleared on leave.
|
|
9825
10086
|
*/
|
|
9826
|
-
this.
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
9833
|
-
if (
|
|
9834
|
-
|
|
9835
|
-
|
|
9836
|
-
this.audioContext = undefined;
|
|
10087
|
+
this.setSfuClient = (sfuClient) => {
|
|
10088
|
+
this.sfuClient = sfuClient;
|
|
10089
|
+
};
|
|
10090
|
+
/**
|
|
10091
|
+
* Cancels any pending debounced subscription push. Idempotent.
|
|
10092
|
+
*/
|
|
10093
|
+
this.dispose = () => {
|
|
10094
|
+
if (this.pendingUpdate) {
|
|
10095
|
+
clearTimeout(this.pendingUpdate);
|
|
10096
|
+
this.pendingUpdate = null;
|
|
9837
10097
|
}
|
|
9838
10098
|
};
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
9843
|
-
|
|
10099
|
+
/**
|
|
10100
|
+
* Sets video-subscription overrides. Called by
|
|
10101
|
+
* `Call.setIncomingVideoEnabled` and
|
|
10102
|
+
* `Call.setPreferredIncomingVideoResolution`.
|
|
10103
|
+
*
|
|
10104
|
+
* - `sessionIds` omitted → applies `override` globally (or clears the
|
|
10105
|
+
* global override if `override` is `undefined`).
|
|
10106
|
+
* - `sessionIds` provided → applies `override` to each listed session.
|
|
10107
|
+
*/
|
|
10108
|
+
this.setOverrides = (override, sessionIds) => {
|
|
10109
|
+
this.tracer.trace('setOverrides', [override, sessionIds]);
|
|
9844
10110
|
if (!sessionIds) {
|
|
9845
|
-
return setCurrentValue(this.
|
|
10111
|
+
return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
|
|
9846
10112
|
}
|
|
9847
|
-
return setCurrentValue(this.
|
|
10113
|
+
return setCurrentValue(this.overridesSubject, (overrides) => ({
|
|
9848
10114
|
...overrides,
|
|
9849
10115
|
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
9850
10116
|
}));
|
|
9851
10117
|
};
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
10118
|
+
/**
|
|
10119
|
+
* Pushes `subscriptions` to the SFU. Debounced by `debounceType`
|
|
10120
|
+
* (SLOW by default). Multiple rapid calls coalesce into one RPC.
|
|
10121
|
+
* Passing `0` fires synchronously.
|
|
10122
|
+
*/
|
|
10123
|
+
this.apply = (debounceType = exports.DebounceType.SLOW) => {
|
|
10124
|
+
if (this.pendingUpdate) {
|
|
10125
|
+
clearTimeout(this.pendingUpdate);
|
|
9855
10126
|
}
|
|
9856
10127
|
const updateSubscriptions = () => {
|
|
9857
|
-
this.
|
|
10128
|
+
this.pendingUpdate = null;
|
|
9858
10129
|
this.sfuClient
|
|
9859
|
-
?.updateSubscriptions(this.
|
|
10130
|
+
?.updateSubscriptions(this.subscriptions)
|
|
9860
10131
|
.catch((err) => {
|
|
9861
10132
|
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
9862
10133
|
});
|
|
9863
10134
|
};
|
|
9864
10135
|
if (debounceType) {
|
|
9865
|
-
this.
|
|
10136
|
+
this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
|
|
9866
10137
|
}
|
|
9867
10138
|
else {
|
|
9868
10139
|
updateSubscriptions();
|
|
9869
10140
|
}
|
|
9870
10141
|
};
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
10142
|
+
this.tracer = tracer;
|
|
10143
|
+
this.callState = callState;
|
|
10144
|
+
}
|
|
10145
|
+
/**
|
|
10146
|
+
* The current SFU subscription list, computed from `CallState`
|
|
10147
|
+
* participants and the override state. Used by:
|
|
10148
|
+
*
|
|
10149
|
+
* - `apply()` to push to the SFU each time the set changes.
|
|
10150
|
+
* - `Call.getReconnectDetails` to include the subscription list in
|
|
10151
|
+
* the reconnect payload.
|
|
10152
|
+
*/
|
|
10153
|
+
get subscriptions() {
|
|
10154
|
+
const subscriptions = [];
|
|
10155
|
+
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
10156
|
+
// and avoid stale data caused by shareReplay with no active subscribers
|
|
10157
|
+
const participants = this.callState.getParticipantsSnapshot();
|
|
10158
|
+
const overrides = this.overridesSubject.getValue();
|
|
10159
|
+
for (const p of participants) {
|
|
10160
|
+
if (p.isLocalParticipant)
|
|
10161
|
+
continue;
|
|
10162
|
+
// NOTE: audio tracks don't have to be requested explicitly
|
|
10163
|
+
// as the SFU will implicitly subscribe us to all of them,
|
|
10164
|
+
// once they become available.
|
|
10165
|
+
if (p.videoDimension && hasVideo(p)) {
|
|
10166
|
+
const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
|
|
10167
|
+
if (override?.enabled !== false) {
|
|
10168
|
+
subscriptions.push({
|
|
10169
|
+
userId: p.userId,
|
|
10170
|
+
sessionId: p.sessionId,
|
|
10171
|
+
trackType: TrackType.VIDEO,
|
|
10172
|
+
dimension: override?.dimension ?? p.videoDimension,
|
|
10173
|
+
});
|
|
10174
|
+
}
|
|
10175
|
+
}
|
|
10176
|
+
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
10177
|
+
subscriptions.push({
|
|
10178
|
+
userId: p.userId,
|
|
10179
|
+
sessionId: p.sessionId,
|
|
10180
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10181
|
+
dimension: p.screenShareDimension,
|
|
9897
10182
|
});
|
|
10183
|
+
}
|
|
10184
|
+
if (hasScreenShareAudio(p)) {
|
|
10185
|
+
subscriptions.push({
|
|
10186
|
+
userId: p.userId,
|
|
10187
|
+
sessionId: p.sessionId,
|
|
10188
|
+
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
10189
|
+
});
|
|
10190
|
+
}
|
|
10191
|
+
}
|
|
10192
|
+
return subscriptions;
|
|
10193
|
+
}
|
|
10194
|
+
get overrides() {
|
|
10195
|
+
return getCurrentValue(this.overrides$);
|
|
10196
|
+
}
|
|
10197
|
+
}
|
|
10198
|
+
|
|
10199
|
+
/**
|
|
10200
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
10201
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
10202
|
+
*/
|
|
10203
|
+
class MediaPlaybackWatchdog {
|
|
10204
|
+
constructor(opts) {
|
|
10205
|
+
this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
10206
|
+
this.controller = new AbortController();
|
|
10207
|
+
this.attempt = 0;
|
|
10208
|
+
this.disposed = false;
|
|
10209
|
+
this.attach = () => {
|
|
10210
|
+
if (this.disposed)
|
|
10211
|
+
return;
|
|
10212
|
+
const { signal } = this.controller;
|
|
10213
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
10214
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
10215
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
10216
|
+
};
|
|
10217
|
+
this.dispose = () => {
|
|
10218
|
+
if (this.disposed)
|
|
10219
|
+
return;
|
|
10220
|
+
this.disposed = true;
|
|
10221
|
+
this.controller.abort();
|
|
10222
|
+
if (this.pendingTimer)
|
|
10223
|
+
clearTimeout(this.pendingTimer);
|
|
10224
|
+
this.pendingTimer = undefined;
|
|
10225
|
+
};
|
|
10226
|
+
this.onPlaying = () => {
|
|
10227
|
+
if (this.attempt > 0) {
|
|
10228
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
10229
|
+
kind: this.kind,
|
|
10230
|
+
attempts: this.attempt,
|
|
10231
|
+
});
|
|
10232
|
+
}
|
|
10233
|
+
this.attempt = 0;
|
|
10234
|
+
if (this.pendingTimer)
|
|
10235
|
+
clearTimeout(this.pendingTimer);
|
|
10236
|
+
this.pendingTimer = undefined;
|
|
10237
|
+
};
|
|
10238
|
+
this.onPauseOrSuspend = (event) => {
|
|
10239
|
+
if (this.disposed)
|
|
10240
|
+
return;
|
|
10241
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
10242
|
+
kind: this.kind,
|
|
10243
|
+
reason: event.type,
|
|
9898
10244
|
});
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
viewportVisibilityState: {
|
|
9910
|
-
...previousVisibilityState,
|
|
9911
|
-
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
9912
|
-
},
|
|
9913
|
-
};
|
|
10245
|
+
this.scheduleRecovery();
|
|
10246
|
+
};
|
|
10247
|
+
this.scheduleRecovery = () => {
|
|
10248
|
+
if (this.disposed || this.pendingTimer)
|
|
10249
|
+
return;
|
|
10250
|
+
const skipReason = this.computeSkipReason();
|
|
10251
|
+
if (skipReason) {
|
|
10252
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
10253
|
+
kind: this.kind,
|
|
10254
|
+
reason: skipReason,
|
|
9914
10255
|
});
|
|
9915
|
-
|
|
10256
|
+
return;
|
|
10257
|
+
}
|
|
10258
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
10259
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
10260
|
+
};
|
|
10261
|
+
this.computeSkipReason = () => {
|
|
10262
|
+
if (this.disposed)
|
|
10263
|
+
return 'disposed';
|
|
10264
|
+
if (!this.element.srcObject)
|
|
10265
|
+
return 'noSrc';
|
|
10266
|
+
if (this.element.ended)
|
|
10267
|
+
return 'ended';
|
|
10268
|
+
if (this.isBlocked())
|
|
10269
|
+
return 'blocked';
|
|
10270
|
+
const HAVE_CURRENT_DATA = 2;
|
|
10271
|
+
if (this.element.readyState < HAVE_CURRENT_DATA)
|
|
10272
|
+
return 'notReady';
|
|
10273
|
+
if (!this.element.paused)
|
|
10274
|
+
return 'notPaused';
|
|
10275
|
+
};
|
|
10276
|
+
this.attemptPlay = async () => {
|
|
10277
|
+
this.pendingTimer = undefined;
|
|
10278
|
+
if (this.disposed)
|
|
10279
|
+
return;
|
|
10280
|
+
this.attempt += 1;
|
|
10281
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
10282
|
+
kind: this.kind,
|
|
10283
|
+
attempt: this.attempt,
|
|
10284
|
+
});
|
|
10285
|
+
try {
|
|
10286
|
+
await timeboxed([this.element.play()], 2000);
|
|
10287
|
+
}
|
|
10288
|
+
catch (err) {
|
|
10289
|
+
if (this.disposed)
|
|
10290
|
+
return;
|
|
10291
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
10292
|
+
if (this.attempt >= 10) {
|
|
10293
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
10294
|
+
kind: this.kind,
|
|
10295
|
+
attempts: this.attempt,
|
|
10296
|
+
});
|
|
10297
|
+
return;
|
|
10298
|
+
}
|
|
10299
|
+
this.scheduleRecovery();
|
|
10300
|
+
}
|
|
9916
10301
|
};
|
|
10302
|
+
this.element = opts.element;
|
|
10303
|
+
this.kind = opts.kind;
|
|
10304
|
+
this.tracer = opts.tracer;
|
|
10305
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
10306
|
+
this.attach();
|
|
10307
|
+
}
|
|
10308
|
+
}
|
|
10309
|
+
|
|
10310
|
+
/**
|
|
10311
|
+
* A manager class that handles dynascale related tasks like:
|
|
10312
|
+
*
|
|
10313
|
+
* - binding video elements to session ids
|
|
10314
|
+
* - binding audio elements to session ids
|
|
10315
|
+
*/
|
|
10316
|
+
class DynascaleManager {
|
|
10317
|
+
/**
|
|
10318
|
+
* Creates a new DynascaleManager instance.
|
|
10319
|
+
*/
|
|
10320
|
+
constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
|
|
10321
|
+
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
10322
|
+
this.useWebAudio = false;
|
|
9917
10323
|
/**
|
|
9918
|
-
*
|
|
9919
|
-
*
|
|
9920
|
-
* @param element the viewport element.
|
|
10324
|
+
* Closes the audio context if it was created.
|
|
9921
10325
|
*/
|
|
9922
|
-
this.
|
|
9923
|
-
|
|
10326
|
+
this.dispose = async () => {
|
|
10327
|
+
const context = this.audioContext;
|
|
10328
|
+
if (context && context.state !== 'closed') {
|
|
10329
|
+
document.removeEventListener('click', this.resumeAudioContext);
|
|
10330
|
+
await context.close();
|
|
10331
|
+
this.audioContext = undefined;
|
|
10332
|
+
}
|
|
9924
10333
|
};
|
|
9925
10334
|
/**
|
|
9926
10335
|
* Sets whether to use WebAudio API for audio playback.
|
|
@@ -9965,7 +10374,7 @@ class DynascaleManager {
|
|
|
9965
10374
|
this.callState.updateParticipantTracks(trackType, {
|
|
9966
10375
|
[sessionId]: { dimension },
|
|
9967
10376
|
});
|
|
9968
|
-
this.
|
|
10377
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
9969
10378
|
};
|
|
9970
10379
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((participant) => !!participant), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
9971
10380
|
/**
|
|
@@ -10054,6 +10463,11 @@ class DynascaleManager {
|
|
|
10054
10463
|
// without prior user interaction:
|
|
10055
10464
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
10056
10465
|
videoElement.muted = true;
|
|
10466
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
10467
|
+
element: videoElement,
|
|
10468
|
+
kind: 'video',
|
|
10469
|
+
tracer: this.tracer,
|
|
10470
|
+
});
|
|
10057
10471
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
10058
10472
|
const streamSubscription = participant$
|
|
10059
10473
|
.pipe(rxjs.distinctUntilKeyChanged(trackKey))
|
|
@@ -10063,14 +10477,14 @@ class DynascaleManager {
|
|
|
10063
10477
|
return;
|
|
10064
10478
|
videoElement.srcObject = source ?? null;
|
|
10065
10479
|
if (isSafari() || isFirefox()) {
|
|
10066
|
-
setTimeout(() => {
|
|
10480
|
+
setTimeout(async () => {
|
|
10067
10481
|
videoElement.srcObject = source ?? null;
|
|
10068
|
-
|
|
10482
|
+
try {
|
|
10483
|
+
await timeboxed([videoElement.play()], 2000);
|
|
10484
|
+
}
|
|
10485
|
+
catch (e) {
|
|
10069
10486
|
this.logger.warn(`Failed to play stream`, e);
|
|
10070
|
-
}
|
|
10071
|
-
// we add extra delay until we attempt to force-play
|
|
10072
|
-
// the participant's media stream in Firefox and Safari,
|
|
10073
|
-
// as they seem to have some timing issues
|
|
10487
|
+
}
|
|
10074
10488
|
}, 25);
|
|
10075
10489
|
}
|
|
10076
10490
|
});
|
|
@@ -10080,6 +10494,7 @@ class DynascaleManager {
|
|
|
10080
10494
|
publishedTracksSubscription?.unsubscribe();
|
|
10081
10495
|
streamSubscription.unsubscribe();
|
|
10082
10496
|
resizeObserver?.disconnect();
|
|
10497
|
+
playbackWatchdog.dispose();
|
|
10083
10498
|
};
|
|
10084
10499
|
};
|
|
10085
10500
|
/**
|
|
@@ -10097,7 +10512,6 @@ class DynascaleManager {
|
|
|
10097
10512
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
10098
10513
|
if (!participant || participant.isLocalParticipant)
|
|
10099
10514
|
return;
|
|
10100
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
10101
10515
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
10102
10516
|
const updateSinkId = (deviceId, audioContext) => {
|
|
10103
10517
|
if (!deviceId)
|
|
@@ -10116,6 +10530,7 @@ class DynascaleManager {
|
|
|
10116
10530
|
};
|
|
10117
10531
|
let sourceNode = undefined;
|
|
10118
10532
|
let gainNode = undefined;
|
|
10533
|
+
let audioWatchdog = undefined;
|
|
10119
10534
|
const isAudioTrack = trackType === 'audioTrack';
|
|
10120
10535
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
10121
10536
|
const updateMediaStreamSubscription = participant$
|
|
@@ -10126,8 +10541,10 @@ class DynascaleManager {
|
|
|
10126
10541
|
return;
|
|
10127
10542
|
setTimeout(() => {
|
|
10128
10543
|
audioElement.srcObject = source ?? null;
|
|
10544
|
+
audioWatchdog?.dispose();
|
|
10545
|
+
audioWatchdog = undefined;
|
|
10129
10546
|
if (!source) {
|
|
10130
|
-
this.
|
|
10547
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10131
10548
|
return;
|
|
10132
10549
|
}
|
|
10133
10550
|
// Safari has a special quirk that prevents playing audio until the user
|
|
@@ -10155,10 +10572,16 @@ class DynascaleManager {
|
|
|
10155
10572
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
10156
10573
|
if (e.name === 'NotAllowedError') {
|
|
10157
10574
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
10158
|
-
this.
|
|
10575
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
10159
10576
|
}
|
|
10160
10577
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
10161
10578
|
});
|
|
10579
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
10580
|
+
element: audioElement,
|
|
10581
|
+
kind: 'audio',
|
|
10582
|
+
tracer: this.tracer,
|
|
10583
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
10584
|
+
});
|
|
10162
10585
|
}
|
|
10163
10586
|
const { selectedDevice } = this.speaker.state;
|
|
10164
10587
|
if (selectedDevice)
|
|
@@ -10182,38 +10605,17 @@ class DynascaleManager {
|
|
|
10182
10605
|
});
|
|
10183
10606
|
audioElement.autoplay = true;
|
|
10184
10607
|
return () => {
|
|
10185
|
-
this.
|
|
10186
|
-
this.removeBlockedAudioElement(audioElement);
|
|
10608
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10187
10609
|
sinkIdSubscription?.unsubscribe();
|
|
10188
10610
|
volumeSubscription.unsubscribe();
|
|
10189
10611
|
updateMediaStreamSubscription.unsubscribe();
|
|
10190
10612
|
audioElement.srcObject = null;
|
|
10191
10613
|
sourceNode?.disconnect();
|
|
10192
10614
|
gainNode?.disconnect();
|
|
10615
|
+
audioWatchdog?.dispose();
|
|
10616
|
+
audioWatchdog = undefined;
|
|
10193
10617
|
};
|
|
10194
10618
|
};
|
|
10195
|
-
/**
|
|
10196
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10197
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
10198
|
-
*
|
|
10199
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
10200
|
-
*/
|
|
10201
|
-
this.resumeAudio = async () => {
|
|
10202
|
-
this.tracer.trace('resumeAudio', null);
|
|
10203
|
-
const blocked = new Set();
|
|
10204
|
-
await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
|
|
10205
|
-
try {
|
|
10206
|
-
if (el.srcObject) {
|
|
10207
|
-
await el.play();
|
|
10208
|
-
}
|
|
10209
|
-
}
|
|
10210
|
-
catch {
|
|
10211
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
10212
|
-
blocked.add(el);
|
|
10213
|
-
}
|
|
10214
|
-
}));
|
|
10215
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
10216
|
-
};
|
|
10217
10619
|
this.getOrCreateAudioContext = () => {
|
|
10218
10620
|
if (!this.useWebAudio)
|
|
10219
10621
|
return;
|
|
@@ -10266,57 +10668,124 @@ class DynascaleManager {
|
|
|
10266
10668
|
this.callState = callState;
|
|
10267
10669
|
this.speaker = speaker;
|
|
10268
10670
|
this.tracer = tracer;
|
|
10269
|
-
|
|
10270
|
-
|
|
10271
|
-
}
|
|
10272
|
-
}
|
|
10273
|
-
setSfuClient(sfuClient) {
|
|
10274
|
-
this.sfuClient = sfuClient;
|
|
10671
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
10672
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
10275
10673
|
}
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10294
|
-
|
|
10295
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
10299
|
-
}
|
|
10300
|
-
|
|
10301
|
-
|
|
10302
|
-
|
|
10303
|
-
|
|
10304
|
-
trackType: TrackType.SCREEN_SHARE,
|
|
10305
|
-
dimension: p.screenShareDimension,
|
|
10674
|
+
}
|
|
10675
|
+
|
|
10676
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
10677
|
+
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
10678
|
+
videoTrack: exports.VisibilityState.UNKNOWN,
|
|
10679
|
+
screenShareTrack: exports.VisibilityState.UNKNOWN,
|
|
10680
|
+
};
|
|
10681
|
+
class ViewportTracker {
|
|
10682
|
+
constructor(callState) {
|
|
10683
|
+
this.elementHandlerMap = new Map();
|
|
10684
|
+
this.observer = null;
|
|
10685
|
+
// in React children render before viewport is set, add
|
|
10686
|
+
// them to the queue and observe them once the observer is ready
|
|
10687
|
+
this.queueSet = new Set();
|
|
10688
|
+
/**
|
|
10689
|
+
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
10690
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10691
|
+
*/
|
|
10692
|
+
this.setViewport = (viewportElement, options) => {
|
|
10693
|
+
const cleanup = () => {
|
|
10694
|
+
this.observer?.disconnect();
|
|
10695
|
+
this.observer = null;
|
|
10696
|
+
this.elementHandlerMap.clear();
|
|
10697
|
+
};
|
|
10698
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
10699
|
+
entries.forEach((entry) => {
|
|
10700
|
+
const handler = this.elementHandlerMap.get(entry.target);
|
|
10701
|
+
handler?.(entry);
|
|
10306
10702
|
});
|
|
10307
|
-
}
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
10703
|
+
}, {
|
|
10704
|
+
root: viewportElement,
|
|
10705
|
+
...options,
|
|
10706
|
+
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
10707
|
+
});
|
|
10708
|
+
if (this.queueSet.size) {
|
|
10709
|
+
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
10710
|
+
// check if element which requested observation is
|
|
10711
|
+
// a child of a viewport element, skip if isn't
|
|
10712
|
+
if (!viewportElement.contains(queueElement))
|
|
10713
|
+
return;
|
|
10714
|
+
this.observer.observe(queueElement);
|
|
10715
|
+
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
10313
10716
|
});
|
|
10717
|
+
this.queueSet.clear();
|
|
10314
10718
|
}
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
|
|
10319
|
-
|
|
10719
|
+
return cleanup;
|
|
10720
|
+
};
|
|
10721
|
+
/**
|
|
10722
|
+
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
10723
|
+
* detects a possible change in element's visibility within specified viewport, returns
|
|
10724
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10725
|
+
*/
|
|
10726
|
+
this.observe = (element, handler) => {
|
|
10727
|
+
const queueItem = [element, handler];
|
|
10728
|
+
const cleanup = () => {
|
|
10729
|
+
this.elementHandlerMap.delete(element);
|
|
10730
|
+
this.observer?.unobserve(element);
|
|
10731
|
+
this.queueSet.delete(queueItem);
|
|
10732
|
+
};
|
|
10733
|
+
if (this.elementHandlerMap.has(element))
|
|
10734
|
+
return cleanup;
|
|
10735
|
+
if (!this.observer) {
|
|
10736
|
+
this.queueSet.add(queueItem);
|
|
10737
|
+
return cleanup;
|
|
10738
|
+
}
|
|
10739
|
+
if (this.observer.root.contains(element)) {
|
|
10740
|
+
this.elementHandlerMap.set(element, handler);
|
|
10741
|
+
this.observer.observe(element);
|
|
10742
|
+
}
|
|
10743
|
+
return cleanup;
|
|
10744
|
+
};
|
|
10745
|
+
/**
|
|
10746
|
+
* Tracks the given element for visibility changes and mirrors the result
|
|
10747
|
+
* into `participant.viewportVisibilityState[trackType]` in `CallState`.
|
|
10748
|
+
* Returns a function that unobserves the element and resets the visibility
|
|
10749
|
+
* state back to `UNKNOWN`.
|
|
10750
|
+
*/
|
|
10751
|
+
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
10752
|
+
const cleanup = this.observe(element, (entry) => {
|
|
10753
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10754
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10755
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10756
|
+
// observer triggers when the element is "moved" to be a fullscreen element
|
|
10757
|
+
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
10758
|
+
const isVisible = entry.isIntersecting || document.fullscreenElement === element
|
|
10759
|
+
? exports.VisibilityState.VISIBLE
|
|
10760
|
+
: exports.VisibilityState.INVISIBLE;
|
|
10761
|
+
return {
|
|
10762
|
+
...participant,
|
|
10763
|
+
viewportVisibilityState: {
|
|
10764
|
+
...previousVisibilityState,
|
|
10765
|
+
[trackType]: isVisible,
|
|
10766
|
+
},
|
|
10767
|
+
};
|
|
10768
|
+
});
|
|
10769
|
+
});
|
|
10770
|
+
return () => {
|
|
10771
|
+
cleanup();
|
|
10772
|
+
// reset visibility state to UNKNOWN upon cleanup
|
|
10773
|
+
// so that the layouts that are not actively observed
|
|
10774
|
+
// can still function normally (runtime layout switching)
|
|
10775
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10776
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10777
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10778
|
+
return {
|
|
10779
|
+
...participant,
|
|
10780
|
+
viewportVisibilityState: {
|
|
10781
|
+
...previousVisibilityState,
|
|
10782
|
+
[trackType]: exports.VisibilityState.UNKNOWN,
|
|
10783
|
+
},
|
|
10784
|
+
};
|
|
10785
|
+
});
|
|
10786
|
+
};
|
|
10787
|
+
};
|
|
10788
|
+
this.callState = callState;
|
|
10320
10789
|
}
|
|
10321
10790
|
}
|
|
10322
10791
|
|
|
@@ -10983,8 +11452,8 @@ const normalize = (options) => {
|
|
|
10983
11452
|
: false,
|
|
10984
11453
|
};
|
|
10985
11454
|
};
|
|
10986
|
-
const createSyntheticDevice = (deviceId, kind) => {
|
|
10987
|
-
return { deviceId, kind, label
|
|
11455
|
+
const createSyntheticDevice = (deviceId, kind, label = '') => {
|
|
11456
|
+
return { deviceId, kind, label, groupId: '' };
|
|
10988
11457
|
};
|
|
10989
11458
|
const readPreferences = (storageKey) => {
|
|
10990
11459
|
try {
|
|
@@ -11034,9 +11503,12 @@ class DeviceManager {
|
|
|
11034
11503
|
*/
|
|
11035
11504
|
this.stopOnLeave = true;
|
|
11036
11505
|
this.subscriptions = [];
|
|
11506
|
+
this.currentStreamCleanups = [];
|
|
11037
11507
|
this.areSubscriptionsSetUp = false;
|
|
11038
11508
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
11039
11509
|
this.filters = [];
|
|
11510
|
+
this.virtualDevicesSubject = new rxjs.BehaviorSubject([]);
|
|
11511
|
+
this.virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
|
|
11040
11512
|
this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
|
|
11041
11513
|
this.filterRegistrationConcurrencyTag = Symbol('filterRegistrationConcurrencyTag');
|
|
11042
11514
|
/**
|
|
@@ -11045,9 +11517,30 @@ class DeviceManager {
|
|
|
11045
11517
|
* @internal
|
|
11046
11518
|
*/
|
|
11047
11519
|
this.dispose = () => {
|
|
11520
|
+
this.runCurrentStreamCleanups();
|
|
11048
11521
|
this.subscriptions.forEach((s) => s());
|
|
11049
11522
|
this.subscriptions = [];
|
|
11050
11523
|
this.areSubscriptionsSetUp = false;
|
|
11524
|
+
this.virtualDevicesSubject.next([]);
|
|
11525
|
+
};
|
|
11526
|
+
this.runCurrentStreamCleanups = () => {
|
|
11527
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
11528
|
+
this.currentStreamCleanups = [];
|
|
11529
|
+
};
|
|
11530
|
+
this.setLocalInterrupted = (interrupted) => {
|
|
11531
|
+
const localParticipant = this.call.state.localParticipant;
|
|
11532
|
+
if (!localParticipant)
|
|
11533
|
+
return;
|
|
11534
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
11535
|
+
const current = p.interruptedTracks ?? [];
|
|
11536
|
+
const has = current.includes(this.trackType);
|
|
11537
|
+
if (interrupted === has)
|
|
11538
|
+
return {};
|
|
11539
|
+
const next = interrupted
|
|
11540
|
+
? pushToIfMissing([...current], this.trackType)
|
|
11541
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
11542
|
+
return { interruptedTracks: next };
|
|
11543
|
+
});
|
|
11051
11544
|
};
|
|
11052
11545
|
this.call = call;
|
|
11053
11546
|
this.state = state;
|
|
@@ -11080,14 +11573,100 @@ class DeviceManager {
|
|
|
11080
11573
|
}
|
|
11081
11574
|
}
|
|
11082
11575
|
/**
|
|
11083
|
-
* Lists the available audio/video devices
|
|
11576
|
+
* Lists the available audio/video devices
|
|
11577
|
+
*
|
|
11578
|
+
* Note: It prompts the user for a permission to use devices (if not already granted)
|
|
11579
|
+
*
|
|
11580
|
+
* @returns an Observable that will be updated if a device is connected or disconnected
|
|
11581
|
+
*/
|
|
11582
|
+
listDevices() {
|
|
11583
|
+
return rxjs.combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(rxjs.map(([real, virtual]) => [
|
|
11584
|
+
...real,
|
|
11585
|
+
...virtual.map((d) => createSyntheticDevice(d.deviceId, d.kind, d.label)),
|
|
11586
|
+
]));
|
|
11587
|
+
}
|
|
11588
|
+
/**
|
|
11589
|
+
* Registers a virtual camera or microphone backed by a caller-supplied
|
|
11590
|
+
* stream factory. The device appears in `listDevices()` and can be selected
|
|
11591
|
+
* via `select()` like any real device.
|
|
11084
11592
|
*
|
|
11085
|
-
*
|
|
11593
|
+
* Web only. React Native is not supported.
|
|
11086
11594
|
*
|
|
11087
|
-
*
|
|
11595
|
+
* Only supported for camera and microphone managers; calling on any other
|
|
11596
|
+
* manager throws.
|
|
11088
11597
|
*/
|
|
11089
|
-
|
|
11090
|
-
|
|
11598
|
+
registerVirtualDevice(virtualDevice) {
|
|
11599
|
+
if (isReactNative()) {
|
|
11600
|
+
throw new Error('Virtual devices are not supported on React Native.');
|
|
11601
|
+
}
|
|
11602
|
+
if (this.trackType !== TrackType.AUDIO &&
|
|
11603
|
+
this.trackType !== TrackType.VIDEO) {
|
|
11604
|
+
throw new Error('Virtual devices are only supported for camera and microphone.');
|
|
11605
|
+
}
|
|
11606
|
+
const deviceId = `stream-virtual:${generateUUIDv4()}`;
|
|
11607
|
+
const entry = {
|
|
11608
|
+
deviceId,
|
|
11609
|
+
kind: this.mediaDeviceKind,
|
|
11610
|
+
...virtualDevice,
|
|
11611
|
+
};
|
|
11612
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => [
|
|
11613
|
+
...current,
|
|
11614
|
+
entry,
|
|
11615
|
+
]);
|
|
11616
|
+
return {
|
|
11617
|
+
deviceId: entry.deviceId,
|
|
11618
|
+
unregister: async () => {
|
|
11619
|
+
await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11620
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => current.filter((d) => d !== entry));
|
|
11621
|
+
if (this.activeVirtualSession?.deviceId === deviceId) {
|
|
11622
|
+
await this.stopActiveVirtualSession();
|
|
11623
|
+
}
|
|
11624
|
+
});
|
|
11625
|
+
if (this.state.selectedDevice === deviceId) {
|
|
11626
|
+
await this.statusChangeSettled();
|
|
11627
|
+
await this.disable({ forceStop: true });
|
|
11628
|
+
await this.select(undefined);
|
|
11629
|
+
}
|
|
11630
|
+
},
|
|
11631
|
+
};
|
|
11632
|
+
}
|
|
11633
|
+
sanitizeVirtualStream(stream) {
|
|
11634
|
+
stream.getTracks().forEach((track) => {
|
|
11635
|
+
const originalGetSettings = track.getSettings.bind(track);
|
|
11636
|
+
track.getSettings = () => {
|
|
11637
|
+
const settings = originalGetSettings();
|
|
11638
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11639
|
+
const { deviceId, ...rest } = settings;
|
|
11640
|
+
return rest;
|
|
11641
|
+
};
|
|
11642
|
+
});
|
|
11643
|
+
return stream;
|
|
11644
|
+
}
|
|
11645
|
+
findVirtualDevice(deviceId) {
|
|
11646
|
+
if (!deviceId)
|
|
11647
|
+
return undefined;
|
|
11648
|
+
return getCurrentValue(this.virtualDevicesSubject).find((d) => d.deviceId === deviceId);
|
|
11649
|
+
}
|
|
11650
|
+
async stopActiveVirtualSession() {
|
|
11651
|
+
const session = this.activeVirtualSession;
|
|
11652
|
+
this.activeVirtualSession = undefined;
|
|
11653
|
+
await session?.stop?.();
|
|
11654
|
+
}
|
|
11655
|
+
async getSelectedStream(constraints) {
|
|
11656
|
+
const deviceId = this.state.selectedDevice;
|
|
11657
|
+
if (!deviceId?.startsWith('stream-virtual')) {
|
|
11658
|
+
return this.getStream(constraints);
|
|
11659
|
+
}
|
|
11660
|
+
return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11661
|
+
const virtualDevice = this.findVirtualDevice(deviceId);
|
|
11662
|
+
if (!virtualDevice) {
|
|
11663
|
+
throw new Error(`Virtual device is not registered: ${deviceId}`);
|
|
11664
|
+
}
|
|
11665
|
+
await this.stopActiveVirtualSession();
|
|
11666
|
+
const { stream, stop } = await virtualDevice.getUserMedia(constraints);
|
|
11667
|
+
this.activeVirtualSession = { deviceId, stop };
|
|
11668
|
+
return this.sanitizeVirtualStream(stream);
|
|
11669
|
+
});
|
|
11091
11670
|
}
|
|
11092
11671
|
/**
|
|
11093
11672
|
* Returns `true` when this device is in enabled state.
|
|
@@ -11247,6 +11826,9 @@ class DeviceManager {
|
|
|
11247
11826
|
}
|
|
11248
11827
|
});
|
|
11249
11828
|
}
|
|
11829
|
+
getResolvedConstraints(constraints) {
|
|
11830
|
+
return constraints;
|
|
11831
|
+
}
|
|
11250
11832
|
publishStream(stream, options) {
|
|
11251
11833
|
return this.call.publish(stream, this.trackType, options);
|
|
11252
11834
|
}
|
|
@@ -11267,12 +11849,15 @@ class DeviceManager {
|
|
|
11267
11849
|
this.muteLocalStream(stopTracks);
|
|
11268
11850
|
const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
|
|
11269
11851
|
if (allEnded) {
|
|
11852
|
+
await this.stopActiveVirtualSession();
|
|
11270
11853
|
// @ts-expect-error release() is present in react-native-webrtc
|
|
11271
11854
|
if (typeof mediaStream.release === 'function') {
|
|
11272
11855
|
// @ts-expect-error called to dispose the stream in RN
|
|
11273
11856
|
mediaStream.release();
|
|
11274
11857
|
}
|
|
11858
|
+
this.runCurrentStreamCleanups();
|
|
11275
11859
|
this.state.setMediaStream(undefined, undefined);
|
|
11860
|
+
this.setLocalInterrupted(false);
|
|
11276
11861
|
this.filters.forEach((entry) => entry.stop?.());
|
|
11277
11862
|
}
|
|
11278
11863
|
}
|
|
@@ -11308,20 +11893,24 @@ class DeviceManager {
|
|
|
11308
11893
|
async unmuteStream() {
|
|
11309
11894
|
this.logger.debug('Starting stream');
|
|
11310
11895
|
let stream;
|
|
11311
|
-
let
|
|
11896
|
+
let rootStreamPromise;
|
|
11312
11897
|
if (this.state.mediaStream &&
|
|
11313
11898
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
11314
11899
|
stream = this.state.mediaStream;
|
|
11315
11900
|
this.enableTracks();
|
|
11316
11901
|
}
|
|
11317
11902
|
else {
|
|
11903
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
11904
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
11905
|
+
// before chainWith below registers new ones for the new chain.
|
|
11906
|
+
this.runCurrentStreamCleanups();
|
|
11318
11907
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11319
|
-
const constraints = {
|
|
11908
|
+
const constraints = this.getResolvedConstraints({
|
|
11320
11909
|
...defaultConstraints,
|
|
11321
11910
|
deviceId: this.state.selectedDevice
|
|
11322
11911
|
? { exact: this.state.selectedDevice }
|
|
11323
11912
|
: undefined,
|
|
11324
|
-
};
|
|
11913
|
+
});
|
|
11325
11914
|
/**
|
|
11326
11915
|
* Chains two media streams together.
|
|
11327
11916
|
*
|
|
@@ -11370,7 +11959,7 @@ class DeviceManager {
|
|
|
11370
11959
|
});
|
|
11371
11960
|
};
|
|
11372
11961
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
11373
|
-
this.
|
|
11962
|
+
this.currentStreamCleanups.push(() => {
|
|
11374
11963
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
11375
11964
|
});
|
|
11376
11965
|
});
|
|
@@ -11378,7 +11967,7 @@ class DeviceManager {
|
|
|
11378
11967
|
};
|
|
11379
11968
|
// the rootStream represents the stream coming from the actual device
|
|
11380
11969
|
// e.g. camera or microphone stream
|
|
11381
|
-
|
|
11970
|
+
rootStreamPromise = this.getSelectedStream(constraints);
|
|
11382
11971
|
// we publish the last MediaStream of the chain
|
|
11383
11972
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11384
11973
|
.then((inputStream) => {
|
|
@@ -11389,42 +11978,70 @@ class DeviceManager {
|
|
|
11389
11978
|
.then(chainWith(parent), (error) => {
|
|
11390
11979
|
this.logger.warn('Filter failed to start and will be ignored', error);
|
|
11391
11980
|
return parent;
|
|
11392
|
-
}),
|
|
11981
|
+
}), rootStreamPromise);
|
|
11393
11982
|
}
|
|
11394
11983
|
if (this.call.state.callingState === exports.CallingState.JOINED) {
|
|
11395
11984
|
await this.publishStream(stream);
|
|
11396
11985
|
}
|
|
11397
11986
|
if (this.state.mediaStream !== stream) {
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
this.
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11407
|
-
|
|
11408
|
-
|
|
11409
|
-
|
|
11410
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
this.
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
|
|
11418
|
-
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11987
|
+
const rootStream = await rootStreamPromise;
|
|
11988
|
+
this.state.setMediaStream(stream, rootStream);
|
|
11989
|
+
if (rootStream) {
|
|
11990
|
+
const handleTrackEnded = async () => {
|
|
11991
|
+
this.setLocalInterrupted(false);
|
|
11992
|
+
await this.statusChangeSettled();
|
|
11993
|
+
if (this.enabled) {
|
|
11994
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
11995
|
+
setTimeout(() => {
|
|
11996
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
11997
|
+
}, 2000);
|
|
11998
|
+
await this.disable();
|
|
11999
|
+
}
|
|
12000
|
+
};
|
|
12001
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
12002
|
+
this.setLocalInterrupted(muted);
|
|
12003
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
12004
|
+
// macOS audio session interruption even though the track is
|
|
12005
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
12006
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
12007
|
+
// page is hidden because the encoder won't resume until
|
|
12008
|
+
// foreground anyway.
|
|
12009
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
12010
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
12011
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
12012
|
+
});
|
|
12013
|
+
}
|
|
12014
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
12015
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
12016
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
12017
|
+
trackType: TrackType[this.trackType],
|
|
12018
|
+
muted,
|
|
12019
|
+
});
|
|
12020
|
+
this.call
|
|
12021
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
12022
|
+
.catch((err) => {
|
|
12023
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
12024
|
+
});
|
|
12025
|
+
}
|
|
12026
|
+
};
|
|
12027
|
+
rootStream.getTracks().forEach((track) => {
|
|
12028
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
12029
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
12030
|
+
track.addEventListener('mute', muteHandler);
|
|
12031
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
12032
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
12033
|
+
this.currentStreamCleanups.push(() => {
|
|
12034
|
+
track.removeEventListener('mute', muteHandler);
|
|
12035
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
12036
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
12037
|
+
});
|
|
11426
12038
|
});
|
|
11427
|
-
|
|
12039
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
12040
|
+
this.setLocalInterrupted(initialMuted);
|
|
12041
|
+
}
|
|
12042
|
+
else {
|
|
12043
|
+
this.setLocalInterrupted(false);
|
|
12044
|
+
}
|
|
11428
12045
|
}
|
|
11429
12046
|
}
|
|
11430
12047
|
get mediaDeviceKind() {
|
|
@@ -11570,7 +12187,6 @@ class DeviceManagerState {
|
|
|
11570
12187
|
this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
11571
12188
|
/**
|
|
11572
12189
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
11573
|
-
*
|
|
11574
12190
|
*/
|
|
11575
12191
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11576
12192
|
/**
|
|
@@ -11668,7 +12284,10 @@ class DeviceManagerState {
|
|
|
11668
12284
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
11669
12285
|
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
11670
12286
|
if (rootStream) {
|
|
11671
|
-
this.
|
|
12287
|
+
const derived = this.getDeviceIdFromStream(rootStream);
|
|
12288
|
+
if (derived) {
|
|
12289
|
+
this.setDevice(derived);
|
|
12290
|
+
}
|
|
11672
12291
|
}
|
|
11673
12292
|
}
|
|
11674
12293
|
/**
|
|
@@ -11881,7 +12500,7 @@ class CameraManager extends DeviceManager {
|
|
|
11881
12500
|
getDevices() {
|
|
11882
12501
|
return getVideoDevices(this.call.tracer);
|
|
11883
12502
|
}
|
|
11884
|
-
|
|
12503
|
+
getResolvedConstraints(constraints) {
|
|
11885
12504
|
constraints.width = this.targetResolution.width;
|
|
11886
12505
|
constraints.height = this.targetResolution.height;
|
|
11887
12506
|
// We can't set both device id and facing mode
|
|
@@ -11892,6 +12511,9 @@ class CameraManager extends DeviceManager {
|
|
|
11892
12511
|
constraints.facingMode =
|
|
11893
12512
|
this.state.direction === 'front' ? 'user' : 'environment';
|
|
11894
12513
|
}
|
|
12514
|
+
return constraints;
|
|
12515
|
+
}
|
|
12516
|
+
getStream(constraints) {
|
|
11895
12517
|
return getVideoStream(constraints, this.call.tracer);
|
|
11896
12518
|
}
|
|
11897
12519
|
}
|
|
@@ -13219,14 +13841,17 @@ class Call {
|
|
|
13219
13841
|
this.sfuStatsReporter?.flush();
|
|
13220
13842
|
this.sfuStatsReporter?.stop();
|
|
13221
13843
|
this.sfuStatsReporter = undefined;
|
|
13222
|
-
this.
|
|
13844
|
+
this.lastStatsOptions = undefined;
|
|
13845
|
+
await this.subscriber?.dispose();
|
|
13223
13846
|
this.subscriber = undefined;
|
|
13224
|
-
this.publisher?.dispose();
|
|
13847
|
+
await this.publisher?.dispose();
|
|
13225
13848
|
this.publisher = undefined;
|
|
13226
13849
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13227
13850
|
this.sfuClient = undefined;
|
|
13228
|
-
this.
|
|
13229
|
-
|
|
13851
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
13852
|
+
this.trackSubscriptionManager.dispose();
|
|
13853
|
+
this.audioBindingsWatchdog?.dispose();
|
|
13854
|
+
await this.dynascaleManager?.dispose();
|
|
13230
13855
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
13231
13856
|
this.state.setParticipants([]);
|
|
13232
13857
|
this.state.dispose();
|
|
@@ -13495,15 +14120,17 @@ class Call {
|
|
|
13495
14120
|
const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13496
14121
|
const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
|
|
13497
14122
|
const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
|
|
13498
|
-
let statsOptions = this.
|
|
14123
|
+
let statsOptions = this.lastStatsOptions;
|
|
13499
14124
|
if (!this.credentials ||
|
|
13500
14125
|
!statsOptions ||
|
|
13501
14126
|
performingRejoin ||
|
|
13502
|
-
performingMigration
|
|
14127
|
+
performingMigration ||
|
|
14128
|
+
data?.migrating_from) {
|
|
13503
14129
|
try {
|
|
13504
14130
|
const joinResponse = await this.doJoinRequest(data);
|
|
13505
14131
|
this.credentials = joinResponse.credentials;
|
|
13506
14132
|
statsOptions = joinResponse.stats_options;
|
|
14133
|
+
this.lastStatsOptions = statsOptions;
|
|
13507
14134
|
}
|
|
13508
14135
|
catch (error) {
|
|
13509
14136
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
@@ -13536,7 +14163,7 @@ class Call {
|
|
|
13536
14163
|
: previousSfuClient;
|
|
13537
14164
|
this.sfuClient = sfuClient;
|
|
13538
14165
|
this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
|
|
13539
|
-
this.
|
|
14166
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
13540
14167
|
const clientDetails = await getClientDetails();
|
|
13541
14168
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
13542
14169
|
if (previousSfuClient !== sfuClient) {
|
|
@@ -13610,7 +14237,7 @@ class Call {
|
|
|
13610
14237
|
}
|
|
13611
14238
|
else {
|
|
13612
14239
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
13613
|
-
this.initPublisherAndSubscriber({
|
|
14240
|
+
await this.initPublisherAndSubscriber({
|
|
13614
14241
|
sfuClient,
|
|
13615
14242
|
connectionConfig,
|
|
13616
14243
|
clientDetails,
|
|
@@ -13671,7 +14298,7 @@ class Call {
|
|
|
13671
14298
|
return {
|
|
13672
14299
|
strategy,
|
|
13673
14300
|
announcedTracks,
|
|
13674
|
-
subscriptions: this.
|
|
14301
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
13675
14302
|
reconnectAttempt: this.reconnectAttempts,
|
|
13676
14303
|
fromSfuId: migratingFromSfuId || '',
|
|
13677
14304
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -13755,11 +14382,11 @@ class Call {
|
|
|
13755
14382
|
* Initializes the Publisher and Subscriber Peer Connections.
|
|
13756
14383
|
* @internal
|
|
13757
14384
|
*/
|
|
13758
|
-
this.initPublisherAndSubscriber = (opts) => {
|
|
14385
|
+
this.initPublisherAndSubscriber = async (opts) => {
|
|
13759
14386
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
13760
14387
|
const { enable_rtc_stats: enableTracing } = statsOptions;
|
|
13761
14388
|
if (closePreviousInstances && this.subscriber) {
|
|
13762
|
-
this.subscriber.dispose();
|
|
14389
|
+
await this.subscriber.dispose();
|
|
13763
14390
|
}
|
|
13764
14391
|
const basePeerConnectionOptions = {
|
|
13765
14392
|
sfuClient,
|
|
@@ -13788,7 +14415,7 @@ class Call {
|
|
|
13788
14415
|
const isAnonymous = this.streamClient.user?.type === 'anonymous';
|
|
13789
14416
|
if (!isAnonymous) {
|
|
13790
14417
|
if (closePreviousInstances && this.publisher) {
|
|
13791
|
-
this.publisher.dispose();
|
|
14418
|
+
await this.publisher.dispose();
|
|
13792
14419
|
}
|
|
13793
14420
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
|
|
13794
14421
|
}
|
|
@@ -13891,10 +14518,17 @@ class Call {
|
|
|
13891
14518
|
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
13892
14519
|
*/
|
|
13893
14520
|
this.reconnect = async (strategy, reason) => {
|
|
13894
|
-
if (this.state.callingState === exports.CallingState.
|
|
14521
|
+
if (this.state.callingState === exports.CallingState.JOINING ||
|
|
14522
|
+
this.state.callingState === exports.CallingState.RECONNECTING ||
|
|
13895
14523
|
this.state.callingState === exports.CallingState.MIGRATING ||
|
|
13896
14524
|
this.state.callingState === exports.CallingState.RECONNECTING_FAILED)
|
|
13897
14525
|
return;
|
|
14526
|
+
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
14527
|
+
// running for this Call, that entry will resolve whatever broke;
|
|
14528
|
+
// queueing more entries just replays the full REJOIN cycle (one extra
|
|
14529
|
+
// `POST /join` per entry) once the call is already healthy again.
|
|
14530
|
+
if (hasPending(this.reconnectConcurrencyTag))
|
|
14531
|
+
return;
|
|
13898
14532
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
13899
14533
|
const reconnectStartTime = Date.now();
|
|
13900
14534
|
this.reconnectStrategy = strategy;
|
|
@@ -14099,8 +14733,8 @@ class Call {
|
|
|
14099
14733
|
this.state.setCallingState(exports.CallingState.JOINED);
|
|
14100
14734
|
}
|
|
14101
14735
|
finally {
|
|
14102
|
-
currentSubscriber?.dispose();
|
|
14103
|
-
currentPublisher?.dispose();
|
|
14736
|
+
await currentSubscriber?.dispose();
|
|
14737
|
+
await currentPublisher?.dispose();
|
|
14104
14738
|
// and close the previous SFU client, without specifying close code
|
|
14105
14739
|
currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
|
|
14106
14740
|
}
|
|
@@ -14218,7 +14852,7 @@ class Call {
|
|
|
14218
14852
|
const { remoteParticipants } = this.state;
|
|
14219
14853
|
if (remoteParticipants.length <= 0)
|
|
14220
14854
|
return;
|
|
14221
|
-
this.
|
|
14855
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
14222
14856
|
};
|
|
14223
14857
|
/**
|
|
14224
14858
|
* Starts publishing the given video stream to the call.
|
|
@@ -14289,7 +14923,7 @@ class Call {
|
|
|
14289
14923
|
this.stopPublish = async (...trackTypes) => {
|
|
14290
14924
|
if (!this.sfuClient || !this.publisher)
|
|
14291
14925
|
return;
|
|
14292
|
-
this.publisher.stopTracks(...trackTypes);
|
|
14926
|
+
await this.publisher.stopTracks(...trackTypes);
|
|
14293
14927
|
await this.updateLocalStreamState(undefined, ...trackTypes);
|
|
14294
14928
|
};
|
|
14295
14929
|
/**
|
|
@@ -14318,6 +14952,20 @@ class Call {
|
|
|
14318
14952
|
}));
|
|
14319
14953
|
}
|
|
14320
14954
|
};
|
|
14955
|
+
/**
|
|
14956
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
14957
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
14958
|
+
* interruption (Siri, PSTN call).
|
|
14959
|
+
*
|
|
14960
|
+
* @internal
|
|
14961
|
+
*
|
|
14962
|
+
* @param trackType the track type to refresh.
|
|
14963
|
+
*/
|
|
14964
|
+
this.refreshPublishedTrack = async (trackType) => {
|
|
14965
|
+
if (!this.publisher)
|
|
14966
|
+
return;
|
|
14967
|
+
await this.publisher.refreshTrack(trackType);
|
|
14968
|
+
};
|
|
14321
14969
|
/**
|
|
14322
14970
|
* Updates the preferred publishing options
|
|
14323
14971
|
*
|
|
@@ -14979,7 +15627,7 @@ class Call {
|
|
|
14979
15627
|
* @param trackType the video mode.
|
|
14980
15628
|
*/
|
|
14981
15629
|
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
14982
|
-
return this.
|
|
15630
|
+
return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
|
|
14983
15631
|
};
|
|
14984
15632
|
/**
|
|
14985
15633
|
* Sets the viewport element to track bound video elements for visibility.
|
|
@@ -14987,7 +15635,7 @@ class Call {
|
|
|
14987
15635
|
* @param element the viewport element.
|
|
14988
15636
|
*/
|
|
14989
15637
|
this.setViewport = (element) => {
|
|
14990
|
-
return this.
|
|
15638
|
+
return this.viewportTracker?.setViewport(element);
|
|
14991
15639
|
};
|
|
14992
15640
|
/**
|
|
14993
15641
|
* Binds a DOM <video> element to the given session id.
|
|
@@ -15005,7 +15653,7 @@ class Call {
|
|
|
15005
15653
|
* @param trackType the kind of video.
|
|
15006
15654
|
*/
|
|
15007
15655
|
this.bindVideoElement = (videoElement, sessionId, trackType) => {
|
|
15008
|
-
const unbind = this.dynascaleManager
|
|
15656
|
+
const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
|
|
15009
15657
|
if (!unbind)
|
|
15010
15658
|
return;
|
|
15011
15659
|
this.leaveCallHooks.add(unbind);
|
|
@@ -15025,21 +15673,28 @@ class Call {
|
|
|
15025
15673
|
* @param trackType the kind of audio.
|
|
15026
15674
|
*/
|
|
15027
15675
|
this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
|
|
15028
|
-
const unbind = this.dynascaleManager
|
|
15676
|
+
const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
|
|
15029
15677
|
if (!unbind)
|
|
15030
15678
|
return;
|
|
15031
|
-
this.
|
|
15032
|
-
|
|
15033
|
-
this.leaveCallHooks.delete(unbind);
|
|
15679
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
15680
|
+
const cleanup = () => {
|
|
15034
15681
|
unbind();
|
|
15682
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
15683
|
+
};
|
|
15684
|
+
this.leaveCallHooks.add(cleanup);
|
|
15685
|
+
return () => {
|
|
15686
|
+
this.leaveCallHooks.delete(cleanup);
|
|
15687
|
+
cleanup();
|
|
15035
15688
|
};
|
|
15036
15689
|
};
|
|
15037
15690
|
/**
|
|
15038
15691
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
15692
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
15693
|
+
*
|
|
15694
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
15695
|
+
* gesture is required.
|
|
15039
15696
|
*/
|
|
15040
|
-
this.resumeAudio = () =>
|
|
15041
|
-
return this.dynascaleManager.resumeAudio();
|
|
15042
|
-
};
|
|
15697
|
+
this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
15043
15698
|
/**
|
|
15044
15699
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
15045
15700
|
*
|
|
@@ -15077,21 +15732,21 @@ class Call {
|
|
|
15077
15732
|
* preference has effect on. Affects all participants by default.
|
|
15078
15733
|
*/
|
|
15079
15734
|
this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
|
|
15080
|
-
this.
|
|
15735
|
+
this.trackSubscriptionManager.setOverrides(resolution
|
|
15081
15736
|
? {
|
|
15082
15737
|
enabled: true,
|
|
15083
15738
|
dimension: resolution,
|
|
15084
15739
|
}
|
|
15085
15740
|
: undefined, sessionIds);
|
|
15086
|
-
this.
|
|
15741
|
+
this.trackSubscriptionManager.apply();
|
|
15087
15742
|
};
|
|
15088
15743
|
/**
|
|
15089
15744
|
* Enables or disables incoming video from all remote call participants,
|
|
15090
15745
|
* and removes any preference for preferred resolution.
|
|
15091
15746
|
*/
|
|
15092
15747
|
this.setIncomingVideoEnabled = (enabled) => {
|
|
15093
|
-
this.
|
|
15094
|
-
this.
|
|
15748
|
+
this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
|
|
15749
|
+
this.trackSubscriptionManager.apply();
|
|
15095
15750
|
};
|
|
15096
15751
|
/**
|
|
15097
15752
|
* Sets the maximum amount of time a user can remain waiting for a reconnect
|
|
@@ -15172,7 +15827,13 @@ class Call {
|
|
|
15172
15827
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
15173
15828
|
this.speaker = new SpeakerManager(this, preferences);
|
|
15174
15829
|
this.screenShare = new ScreenShareManager(this);
|
|
15175
|
-
this.
|
|
15830
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
|
|
15831
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
15832
|
+
if (typeof document !== 'undefined') {
|
|
15833
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
|
|
15834
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
15835
|
+
this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
|
|
15836
|
+
}
|
|
15176
15837
|
}
|
|
15177
15838
|
/**
|
|
15178
15839
|
* A flag indicating whether the call is "ringing" type of call.
|
|
@@ -15245,12 +15906,118 @@ const APIErrorCodes = {
|
|
|
15245
15906
|
*/
|
|
15246
15907
|
class StableWSConnection {
|
|
15247
15908
|
constructor(client) {
|
|
15909
|
+
/** Incremented when a new WS connection is made */
|
|
15910
|
+
this.wsID = 1;
|
|
15911
|
+
// Connection lifecycle flags.
|
|
15912
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15913
|
+
this.isConnecting = false;
|
|
15914
|
+
/** To avoid reconnect if client is disconnected */
|
|
15915
|
+
this.isDisconnected = false;
|
|
15916
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
15917
|
+
this.isHealthy = false;
|
|
15918
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
15919
|
+
this.isConnectionOpenResolved = false;
|
|
15920
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
15921
|
+
/** consecutive failures influence the duration of the timeout */
|
|
15922
|
+
this.consecutiveFailures = 0;
|
|
15923
|
+
/** keep track of the total number of failures */
|
|
15924
|
+
this.totalFailures = 0;
|
|
15925
|
+
// Health-check pings + connection-staleness check.
|
|
15926
|
+
/** Send a health check message every 25 seconds */
|
|
15927
|
+
this.pingInterval = 25 * 1000;
|
|
15928
|
+
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15929
|
+
/** Store the last event time for health checks */
|
|
15930
|
+
this.lastEvent = null;
|
|
15248
15931
|
this._log = (msg, extra = {}, level = 'info') => {
|
|
15249
15932
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
15250
15933
|
};
|
|
15251
15934
|
this.setClient = (client) => {
|
|
15252
15935
|
this.client = client;
|
|
15253
15936
|
};
|
|
15937
|
+
/**
|
|
15938
|
+
* connect - Connect to the WS URL
|
|
15939
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15940
|
+
* @return Promise that completes once the first health check message is received
|
|
15941
|
+
*/
|
|
15942
|
+
this.connect = async (timeout = 15000) => {
|
|
15943
|
+
if (this.isConnecting) {
|
|
15944
|
+
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15945
|
+
}
|
|
15946
|
+
this.isDisconnected = false;
|
|
15947
|
+
try {
|
|
15948
|
+
const healthCheck = await this._connect(timeout);
|
|
15949
|
+
this.consecutiveFailures = 0;
|
|
15950
|
+
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15951
|
+
}
|
|
15952
|
+
catch (caught) {
|
|
15953
|
+
const error = caught;
|
|
15954
|
+
this.isHealthy = false;
|
|
15955
|
+
this.consecutiveFailures += 1;
|
|
15956
|
+
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15957
|
+
!this.client.tokenManager.isStatic()) {
|
|
15958
|
+
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15959
|
+
this._reconnect({ refreshToken: true });
|
|
15960
|
+
}
|
|
15961
|
+
else if (!error.isWSFailure) {
|
|
15962
|
+
// API rejected the connection and we should not retry
|
|
15963
|
+
throw new Error(JSON.stringify({
|
|
15964
|
+
code: error.code,
|
|
15965
|
+
StatusCode: error.StatusCode,
|
|
15966
|
+
message: error.message,
|
|
15967
|
+
isWSFailure: error.isWSFailure,
|
|
15968
|
+
}));
|
|
15969
|
+
}
|
|
15970
|
+
else {
|
|
15971
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
15972
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
15973
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
15974
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
15975
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
15976
|
+
// own catch when _connect was called from there.
|
|
15977
|
+
this._reconnect();
|
|
15978
|
+
}
|
|
15979
|
+
}
|
|
15980
|
+
return await this._waitForHealthy(timeout);
|
|
15981
|
+
};
|
|
15982
|
+
/**
|
|
15983
|
+
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15984
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15985
|
+
* @param timeout duration(ms)
|
|
15986
|
+
*/
|
|
15987
|
+
this._waitForHealthy = async (timeout = 15000) => {
|
|
15988
|
+
return Promise.race([
|
|
15989
|
+
(async () => {
|
|
15990
|
+
const interval = 50; // ms
|
|
15991
|
+
for (let i = 0; i <= timeout; i += interval) {
|
|
15992
|
+
try {
|
|
15993
|
+
return await this.connectionOpen;
|
|
15994
|
+
}
|
|
15995
|
+
catch (caught) {
|
|
15996
|
+
const error = caught;
|
|
15997
|
+
if (i === timeout) {
|
|
15998
|
+
throw new Error(JSON.stringify({
|
|
15999
|
+
code: error.code,
|
|
16000
|
+
StatusCode: error.StatusCode,
|
|
16001
|
+
message: error.message,
|
|
16002
|
+
isWSFailure: error.isWSFailure,
|
|
16003
|
+
}));
|
|
16004
|
+
}
|
|
16005
|
+
await sleep(interval);
|
|
16006
|
+
}
|
|
16007
|
+
}
|
|
16008
|
+
})(),
|
|
16009
|
+
(async () => {
|
|
16010
|
+
await sleep(timeout);
|
|
16011
|
+
this.isConnecting = false;
|
|
16012
|
+
throw new Error(JSON.stringify({
|
|
16013
|
+
code: '',
|
|
16014
|
+
StatusCode: '',
|
|
16015
|
+
message: 'initial WS connection could not be established',
|
|
16016
|
+
isWSFailure: true,
|
|
16017
|
+
}));
|
|
16018
|
+
})(),
|
|
16019
|
+
]);
|
|
16020
|
+
};
|
|
15254
16021
|
/**
|
|
15255
16022
|
* Builds and returns the url for websocket.
|
|
15256
16023
|
* @private
|
|
@@ -15263,11 +16030,166 @@ class StableWSConnection {
|
|
|
15263
16030
|
params.set('X-Stream-Client', this.client.getUserAgent());
|
|
15264
16031
|
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
|
|
15265
16032
|
};
|
|
16033
|
+
/**
|
|
16034
|
+
* disconnect - Disconnect the connection and doesn't recover...
|
|
16035
|
+
*/
|
|
16036
|
+
this.disconnect = (timeout) => {
|
|
16037
|
+
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
16038
|
+
this.wsID += 1;
|
|
16039
|
+
this.isConnecting = false;
|
|
16040
|
+
this.isDisconnected = true;
|
|
16041
|
+
// start by removing all the listeners
|
|
16042
|
+
if (this.healthCheckTimeoutRef) {
|
|
16043
|
+
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
16044
|
+
}
|
|
16045
|
+
if (this.connectionCheckTimeoutRef) {
|
|
16046
|
+
clearInterval(this.connectionCheckTimeoutRef);
|
|
16047
|
+
}
|
|
16048
|
+
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
16049
|
+
this.isHealthy = false;
|
|
16050
|
+
let isClosedPromise;
|
|
16051
|
+
// and finally close...
|
|
16052
|
+
// Assigning to local here because we will remove it from this before the
|
|
16053
|
+
// promise resolves.
|
|
16054
|
+
const { ws } = this;
|
|
16055
|
+
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
16056
|
+
isClosedPromise = new Promise((resolve) => {
|
|
16057
|
+
const onclose = (event) => {
|
|
16058
|
+
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
16059
|
+
resolve();
|
|
16060
|
+
};
|
|
16061
|
+
ws.onclose = onclose;
|
|
16062
|
+
// In case we don't receive close frame websocket server in time,
|
|
16063
|
+
// lets not wait for more than 1 second.
|
|
16064
|
+
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
16065
|
+
});
|
|
16066
|
+
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
16067
|
+
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
16068
|
+
}
|
|
16069
|
+
else {
|
|
16070
|
+
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
16071
|
+
isClosedPromise = Promise.resolve();
|
|
16072
|
+
}
|
|
16073
|
+
delete this.ws;
|
|
16074
|
+
return isClosedPromise;
|
|
16075
|
+
};
|
|
16076
|
+
/**
|
|
16077
|
+
* _connect - Connect to the WS endpoint
|
|
16078
|
+
*
|
|
16079
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
16080
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
16081
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
16082
|
+
* @return Promise that completes once the first health check message is received
|
|
16083
|
+
*/
|
|
16084
|
+
this._connect = async (timeoutMs) => {
|
|
16085
|
+
if (this.isConnecting)
|
|
16086
|
+
return; // ignore _connect if it's currently trying to connect
|
|
16087
|
+
this.isConnecting = true;
|
|
16088
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
16089
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
16090
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
16091
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
16092
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
16093
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
16094
|
+
// captured closure still settles only the original promise (P1) and
|
|
16095
|
+
// never poisons the newer one (P2).
|
|
16096
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
16097
|
+
let isTokenReady = false;
|
|
16098
|
+
try {
|
|
16099
|
+
this._log(`_connect() - waiting for token`);
|
|
16100
|
+
await this.client.tokenManager.tokenReady();
|
|
16101
|
+
isTokenReady = true;
|
|
16102
|
+
}
|
|
16103
|
+
catch {
|
|
16104
|
+
// token provider has failed before, so try again
|
|
16105
|
+
}
|
|
16106
|
+
try {
|
|
16107
|
+
if (!isTokenReady) {
|
|
16108
|
+
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
16109
|
+
await this.client.tokenManager.loadToken();
|
|
16110
|
+
}
|
|
16111
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
16112
|
+
this.client._setupConnectionIdPromise();
|
|
16113
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
16114
|
+
// closure is the one bound to the promise this attempt owns.
|
|
16115
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
16116
|
+
}
|
|
16117
|
+
this._setupConnectionPromise();
|
|
16118
|
+
const wsURL = this._buildUrl();
|
|
16119
|
+
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
16120
|
+
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
16121
|
+
this.ws = new WS(wsURL);
|
|
16122
|
+
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
16123
|
+
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
16124
|
+
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
16125
|
+
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
16126
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
16127
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
16128
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
16129
|
+
const timers = getTimers();
|
|
16130
|
+
let handshakeTimeoutId;
|
|
16131
|
+
let response;
|
|
16132
|
+
try {
|
|
16133
|
+
response = await Promise.race([
|
|
16134
|
+
this.connectionOpen,
|
|
16135
|
+
new Promise((_, reject) => {
|
|
16136
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
16137
|
+
const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
|
|
16138
|
+
err.isWSFailure = true;
|
|
16139
|
+
reject(err);
|
|
16140
|
+
}, handshakeTimeout);
|
|
16141
|
+
}),
|
|
16142
|
+
]);
|
|
16143
|
+
}
|
|
16144
|
+
finally {
|
|
16145
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
16146
|
+
}
|
|
16147
|
+
this.isConnecting = false;
|
|
16148
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
16149
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
16150
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
16151
|
+
// a misleading "success" for a connection that has already been
|
|
16152
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
16153
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
16154
|
+
// which would observe the already-resolved connectionOpen promise
|
|
16155
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
16156
|
+
if (this.isDisconnected) {
|
|
16157
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
16158
|
+
this._destroyCurrentWSConnection();
|
|
16159
|
+
}
|
|
16160
|
+
throw new Error('WS handshake aborted: disconnect() ran while connecting');
|
|
16161
|
+
}
|
|
16162
|
+
if (response) {
|
|
16163
|
+
this.connectionID = response.connection_id;
|
|
16164
|
+
this.client.resolveConnectionId?.(this.connectionID);
|
|
16165
|
+
return response;
|
|
16166
|
+
}
|
|
16167
|
+
}
|
|
16168
|
+
catch (caught) {
|
|
16169
|
+
const err = caught;
|
|
16170
|
+
this.isConnecting = false;
|
|
16171
|
+
this._log(`_connect() - Error - `, err);
|
|
16172
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
16173
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
16174
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
16175
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
16176
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
16177
|
+
// therefore fail fast instead of being orphaned.
|
|
16178
|
+
ownRejectConnectionId?.(err);
|
|
16179
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
16180
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
16181
|
+
this.rejectConnectionOpen?.(err);
|
|
16182
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
16183
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
16184
|
+
this._destroyCurrentWSConnection();
|
|
16185
|
+
}
|
|
16186
|
+
throw err;
|
|
16187
|
+
}
|
|
16188
|
+
};
|
|
15266
16189
|
/**
|
|
15267
16190
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
15268
16191
|
*
|
|
15269
16192
|
* @param {Event} event Event with type online or offline
|
|
15270
|
-
*
|
|
15271
16193
|
*/
|
|
15272
16194
|
this.onlineStatusChanged = (event) => {
|
|
15273
16195
|
if (event.type === 'offline') {
|
|
@@ -15365,16 +16287,12 @@ class StableWSConnection {
|
|
|
15365
16287
|
return;
|
|
15366
16288
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
15367
16289
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
15368
|
-
// this is a permanent error raised by stream
|
|
16290
|
+
// this is a permanent error raised by stream.
|
|
15369
16291
|
// usually caused by invalid auth details
|
|
15370
16292
|
const error = new Error(`WS connection reject with error ${event.reason}`);
|
|
15371
|
-
// @ts-expect-error type issue
|
|
15372
16293
|
error.reason = event.reason;
|
|
15373
|
-
// @ts-expect-error type issue
|
|
15374
16294
|
error.code = event.code;
|
|
15375
|
-
// @ts-expect-error type issue
|
|
15376
16295
|
error.wasClean = event.wasClean;
|
|
15377
|
-
// @ts-expect-error type issue
|
|
15378
16296
|
error.target = event.target;
|
|
15379
16297
|
this.rejectConnectionOpen?.(error);
|
|
15380
16298
|
this._log(`onclose() - WS connection reject with error ${event.reason}`, {
|
|
@@ -15512,205 +16430,8 @@ class StableWSConnection {
|
|
|
15512
16430
|
}, this.connectionCheckTimeout);
|
|
15513
16431
|
};
|
|
15514
16432
|
this.client = client;
|
|
15515
|
-
/** consecutive failures influence the duration of the timeout */
|
|
15516
|
-
this.consecutiveFailures = 0;
|
|
15517
|
-
/** keep track of the total number of failures */
|
|
15518
|
-
this.totalFailures = 0;
|
|
15519
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15520
|
-
this.isConnecting = false;
|
|
15521
|
-
/** To avoid reconnect if client is disconnected */
|
|
15522
|
-
this.isDisconnected = false;
|
|
15523
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
15524
|
-
this.isConnectionOpenResolved = false;
|
|
15525
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
15526
|
-
this.isHealthy = false;
|
|
15527
|
-
/** Incremented when a new WS connection is made */
|
|
15528
|
-
this.wsID = 1;
|
|
15529
|
-
/** Store the last event time for health checks */
|
|
15530
|
-
this.lastEvent = null;
|
|
15531
|
-
/** Send a health check message every 25 seconds */
|
|
15532
|
-
this.pingInterval = 25 * 1000;
|
|
15533
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15534
16433
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
15535
16434
|
}
|
|
15536
|
-
/**
|
|
15537
|
-
* connect - Connect to the WS URL
|
|
15538
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15539
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15540
|
-
*/
|
|
15541
|
-
async connect(timeout = 15000) {
|
|
15542
|
-
if (this.isConnecting) {
|
|
15543
|
-
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15544
|
-
}
|
|
15545
|
-
this.isDisconnected = false;
|
|
15546
|
-
try {
|
|
15547
|
-
const healthCheck = await this._connect();
|
|
15548
|
-
this.consecutiveFailures = 0;
|
|
15549
|
-
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15550
|
-
}
|
|
15551
|
-
catch (error) {
|
|
15552
|
-
this.isHealthy = false;
|
|
15553
|
-
this.consecutiveFailures += 1;
|
|
15554
|
-
if (
|
|
15555
|
-
// @ts-expect-error type issue
|
|
15556
|
-
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15557
|
-
!this.client.tokenManager.isStatic()) {
|
|
15558
|
-
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15559
|
-
this._reconnect({ refreshToken: true });
|
|
15560
|
-
}
|
|
15561
|
-
else {
|
|
15562
|
-
// @ts-expect-error type issue
|
|
15563
|
-
if (!error.isWSFailure) {
|
|
15564
|
-
// API rejected the connection and we should not retry
|
|
15565
|
-
throw new Error(JSON.stringify({
|
|
15566
|
-
// @ts-expect-error type issue
|
|
15567
|
-
code: error.code,
|
|
15568
|
-
// @ts-expect-error type issue
|
|
15569
|
-
StatusCode: error.StatusCode,
|
|
15570
|
-
// @ts-expect-error type issue
|
|
15571
|
-
message: error.message,
|
|
15572
|
-
// @ts-expect-error type issue
|
|
15573
|
-
isWSFailure: error.isWSFailure,
|
|
15574
|
-
}));
|
|
15575
|
-
}
|
|
15576
|
-
}
|
|
15577
|
-
}
|
|
15578
|
-
return await this._waitForHealthy(timeout);
|
|
15579
|
-
}
|
|
15580
|
-
/**
|
|
15581
|
-
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15582
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15583
|
-
* @param timeout duration(ms)
|
|
15584
|
-
*/
|
|
15585
|
-
async _waitForHealthy(timeout = 15000) {
|
|
15586
|
-
return Promise.race([
|
|
15587
|
-
(async () => {
|
|
15588
|
-
const interval = 50; // ms
|
|
15589
|
-
for (let i = 0; i <= timeout; i += interval) {
|
|
15590
|
-
try {
|
|
15591
|
-
return await this.connectionOpen;
|
|
15592
|
-
}
|
|
15593
|
-
catch (error) {
|
|
15594
|
-
if (i === timeout) {
|
|
15595
|
-
throw new Error(JSON.stringify({
|
|
15596
|
-
code: error.code,
|
|
15597
|
-
StatusCode: error.StatusCode,
|
|
15598
|
-
message: error.message,
|
|
15599
|
-
isWSFailure: error.isWSFailure,
|
|
15600
|
-
}));
|
|
15601
|
-
}
|
|
15602
|
-
await sleep(interval);
|
|
15603
|
-
}
|
|
15604
|
-
}
|
|
15605
|
-
})(),
|
|
15606
|
-
(async () => {
|
|
15607
|
-
await sleep(timeout);
|
|
15608
|
-
this.isConnecting = false;
|
|
15609
|
-
throw new Error(JSON.stringify({
|
|
15610
|
-
code: '',
|
|
15611
|
-
StatusCode: '',
|
|
15612
|
-
message: 'initial WS connection could not be established',
|
|
15613
|
-
isWSFailure: true,
|
|
15614
|
-
}));
|
|
15615
|
-
})(),
|
|
15616
|
-
]);
|
|
15617
|
-
}
|
|
15618
|
-
/**
|
|
15619
|
-
* disconnect - Disconnect the connection and doesn't recover...
|
|
15620
|
-
*
|
|
15621
|
-
*/
|
|
15622
|
-
disconnect(timeout) {
|
|
15623
|
-
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15624
|
-
this.wsID += 1;
|
|
15625
|
-
this.isConnecting = false;
|
|
15626
|
-
this.isDisconnected = true;
|
|
15627
|
-
// start by removing all the listeners
|
|
15628
|
-
if (this.healthCheckTimeoutRef) {
|
|
15629
|
-
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15630
|
-
}
|
|
15631
|
-
if (this.connectionCheckTimeoutRef) {
|
|
15632
|
-
clearInterval(this.connectionCheckTimeoutRef);
|
|
15633
|
-
}
|
|
15634
|
-
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15635
|
-
this.isHealthy = false;
|
|
15636
|
-
let isClosedPromise;
|
|
15637
|
-
// and finally close...
|
|
15638
|
-
// Assigning to local here because we will remove it from this before the
|
|
15639
|
-
// promise resolves.
|
|
15640
|
-
const { ws } = this;
|
|
15641
|
-
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15642
|
-
isClosedPromise = new Promise((resolve) => {
|
|
15643
|
-
const onclose = (event) => {
|
|
15644
|
-
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15645
|
-
resolve();
|
|
15646
|
-
};
|
|
15647
|
-
ws.onclose = onclose;
|
|
15648
|
-
// In case we don't receive close frame websocket server in time,
|
|
15649
|
-
// lets not wait for more than 1 second.
|
|
15650
|
-
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15651
|
-
});
|
|
15652
|
-
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15653
|
-
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15654
|
-
}
|
|
15655
|
-
else {
|
|
15656
|
-
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15657
|
-
isClosedPromise = Promise.resolve();
|
|
15658
|
-
}
|
|
15659
|
-
delete this.ws;
|
|
15660
|
-
return isClosedPromise;
|
|
15661
|
-
}
|
|
15662
|
-
/**
|
|
15663
|
-
* _connect - Connect to the WS endpoint
|
|
15664
|
-
*
|
|
15665
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15666
|
-
*/
|
|
15667
|
-
async _connect() {
|
|
15668
|
-
if (this.isConnecting)
|
|
15669
|
-
return; // ignore _connect if it's currently trying to connect
|
|
15670
|
-
this.isConnecting = true;
|
|
15671
|
-
let isTokenReady = false;
|
|
15672
|
-
try {
|
|
15673
|
-
this._log(`_connect() - waiting for token`);
|
|
15674
|
-
await this.client.tokenManager.tokenReady();
|
|
15675
|
-
isTokenReady = true;
|
|
15676
|
-
}
|
|
15677
|
-
catch {
|
|
15678
|
-
// token provider has failed before, so try again
|
|
15679
|
-
}
|
|
15680
|
-
try {
|
|
15681
|
-
if (!isTokenReady) {
|
|
15682
|
-
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15683
|
-
await this.client.tokenManager.loadToken();
|
|
15684
|
-
}
|
|
15685
|
-
if (!this.client.isConnectionIsPromisePending) {
|
|
15686
|
-
this.client._setupConnectionIdPromise();
|
|
15687
|
-
}
|
|
15688
|
-
this._setupConnectionPromise();
|
|
15689
|
-
const wsURL = this._buildUrl();
|
|
15690
|
-
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15691
|
-
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15692
|
-
this.ws = new WS(wsURL);
|
|
15693
|
-
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15694
|
-
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15695
|
-
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15696
|
-
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15697
|
-
const response = await this.connectionOpen;
|
|
15698
|
-
this.isConnecting = false;
|
|
15699
|
-
if (response) {
|
|
15700
|
-
this.connectionID = response.connection_id;
|
|
15701
|
-
this.client.resolveConnectionId?.(this.connectionID);
|
|
15702
|
-
return response;
|
|
15703
|
-
}
|
|
15704
|
-
}
|
|
15705
|
-
catch (err) {
|
|
15706
|
-
this.client._setupConnectionIdPromise();
|
|
15707
|
-
this.isConnecting = false;
|
|
15708
|
-
// @ts-expect-error type issue
|
|
15709
|
-
this._log(`_connect() - Error - `, err);
|
|
15710
|
-
this.client.rejectConnectionId?.(err);
|
|
15711
|
-
throw err;
|
|
15712
|
-
}
|
|
15713
|
-
}
|
|
15714
16435
|
/**
|
|
15715
16436
|
* _reconnect - Retry the connection to WS endpoint
|
|
15716
16437
|
*
|
|
@@ -15757,7 +16478,8 @@ class StableWSConnection {
|
|
|
15757
16478
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
15758
16479
|
this.consecutiveFailures = 0;
|
|
15759
16480
|
}
|
|
15760
|
-
catch (
|
|
16481
|
+
catch (caught) {
|
|
16482
|
+
const error = caught;
|
|
15761
16483
|
this.isHealthy = false;
|
|
15762
16484
|
this.consecutiveFailures += 1;
|
|
15763
16485
|
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
@@ -16314,7 +17036,7 @@ class StreamClient {
|
|
|
16314
17036
|
this.getUserAgent = () => {
|
|
16315
17037
|
if (!this.cachedUserAgent) {
|
|
16316
17038
|
const { clientAppIdentifier = {} } = this.options;
|
|
16317
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
17039
|
+
const { sdkName = 'js', sdkVersion = "1.51.0", ...extras } = clientAppIdentifier;
|
|
16318
17040
|
this.cachedUserAgent = [
|
|
16319
17041
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16320
17042
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16422,7 +17144,7 @@ class StreamClient {
|
|
|
16422
17144
|
get connectionIdPromise() {
|
|
16423
17145
|
return this.connectionIdPromiseSafe?.();
|
|
16424
17146
|
}
|
|
16425
|
-
get
|
|
17147
|
+
get isConnectionIdPromisePending() {
|
|
16426
17148
|
return this.connectionIdPromiseSafe?.checkPending() ?? false;
|
|
16427
17149
|
}
|
|
16428
17150
|
get wsPromise() {
|