@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.es.js
CHANGED
|
@@ -1399,6 +1399,35 @@ var ClientCapability;
|
|
|
1399
1399
|
*/
|
|
1400
1400
|
ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
|
|
1401
1401
|
})(ClientCapability || (ClientCapability = {}));
|
|
1402
|
+
/**
|
|
1403
|
+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
|
|
1404
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
|
|
1405
|
+
*
|
|
1406
|
+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
|
|
1407
|
+
*/
|
|
1408
|
+
var DegradationPreference;
|
|
1409
|
+
(function (DegradationPreference) {
|
|
1410
|
+
/**
|
|
1411
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
|
|
1412
|
+
*/
|
|
1413
|
+
DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
|
|
1414
|
+
/**
|
|
1415
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
|
|
1416
|
+
*/
|
|
1417
|
+
DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
|
|
1418
|
+
/**
|
|
1419
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
|
|
1420
|
+
*/
|
|
1421
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
|
|
1422
|
+
/**
|
|
1423
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
|
|
1424
|
+
*/
|
|
1425
|
+
DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
|
|
1426
|
+
/**
|
|
1427
|
+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
|
|
1428
|
+
*/
|
|
1429
|
+
DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
|
|
1430
|
+
})(DegradationPreference || (DegradationPreference = {}));
|
|
1402
1431
|
// @generated message type with reflection information, may provide speed optimized methods
|
|
1403
1432
|
class CallState$Type extends MessageType {
|
|
1404
1433
|
constructor() {
|
|
@@ -1668,6 +1697,16 @@ class PublishOption$Type extends MessageType {
|
|
|
1668
1697
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1669
1698
|
T: () => AudioBitrate,
|
|
1670
1699
|
},
|
|
1700
|
+
{
|
|
1701
|
+
no: 11,
|
|
1702
|
+
name: 'degradation_preference',
|
|
1703
|
+
kind: 'enum',
|
|
1704
|
+
T: () => [
|
|
1705
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
1706
|
+
DegradationPreference,
|
|
1707
|
+
'DEGRADATION_PREFERENCE_',
|
|
1708
|
+
],
|
|
1709
|
+
},
|
|
1671
1710
|
]);
|
|
1672
1711
|
}
|
|
1673
1712
|
}
|
|
@@ -2114,6 +2153,7 @@ var models = /*#__PURE__*/Object.freeze({
|
|
|
2114
2153
|
ClientDetails: ClientDetails,
|
|
2115
2154
|
Codec: Codec,
|
|
2116
2155
|
get ConnectionQuality () { return ConnectionQuality; },
|
|
2156
|
+
get DegradationPreference () { return DegradationPreference; },
|
|
2117
2157
|
Device: Device,
|
|
2118
2158
|
Error: Error$2,
|
|
2119
2159
|
get ErrorCode () { return ErrorCode; },
|
|
@@ -3501,6 +3541,16 @@ class VideoSender$Type extends MessageType {
|
|
|
3501
3541
|
kind: 'scalar',
|
|
3502
3542
|
T: 5 /*ScalarType.INT32*/,
|
|
3503
3543
|
},
|
|
3544
|
+
{
|
|
3545
|
+
no: 6,
|
|
3546
|
+
name: 'degradation_preference',
|
|
3547
|
+
kind: 'enum',
|
|
3548
|
+
T: () => [
|
|
3549
|
+
'stream.video.sfu.models.DegradationPreference',
|
|
3550
|
+
DegradationPreference,
|
|
3551
|
+
'DEGRADATION_PREFERENCE_',
|
|
3552
|
+
],
|
|
3553
|
+
},
|
|
3504
3554
|
]);
|
|
3505
3555
|
}
|
|
3506
3556
|
}
|
|
@@ -3866,6 +3916,18 @@ const createSignalClient = (options) => {
|
|
|
3866
3916
|
};
|
|
3867
3917
|
|
|
3868
3918
|
const sleep = (m) => new Promise((r) => setTimeout(r, m));
|
|
3919
|
+
const timeboxed = async (promises, ms) => {
|
|
3920
|
+
let timerId;
|
|
3921
|
+
const timeout = new Promise((_, reject) => {
|
|
3922
|
+
timerId = setTimeout(() => reject(new Error('timebox error')), ms);
|
|
3923
|
+
});
|
|
3924
|
+
try {
|
|
3925
|
+
return await Promise.race([Promise.all(promises), timeout]);
|
|
3926
|
+
}
|
|
3927
|
+
finally {
|
|
3928
|
+
clearTimeout(timerId);
|
|
3929
|
+
}
|
|
3930
|
+
};
|
|
3869
3931
|
function isFunction(value) {
|
|
3870
3932
|
return (value &&
|
|
3871
3933
|
(Object.prototype.toString.call(value) === '[object Function]' ||
|
|
@@ -4605,6 +4667,20 @@ const setCurrentValue = (subject, update) => {
|
|
|
4605
4667
|
subject.next(next);
|
|
4606
4668
|
return next;
|
|
4607
4669
|
};
|
|
4670
|
+
/**
|
|
4671
|
+
* Updates the value of the provided Subject asynchronously.
|
|
4672
|
+
* Locks the subject to prevent concurrent updates.
|
|
4673
|
+
*
|
|
4674
|
+
* @param subject the subject to update.
|
|
4675
|
+
* @param update the update to apply to the subject.
|
|
4676
|
+
*/
|
|
4677
|
+
const setCurrentValueAsync = async (subject, update) => {
|
|
4678
|
+
return withoutConcurrency(subject, async () => {
|
|
4679
|
+
const next = await update(getCurrentValue(subject));
|
|
4680
|
+
subject.next(next);
|
|
4681
|
+
return next;
|
|
4682
|
+
});
|
|
4683
|
+
};
|
|
4608
4684
|
/**
|
|
4609
4685
|
* Updates the value of the provided Subject and returns the previous value
|
|
4610
4686
|
* and a function to roll back the update.
|
|
@@ -4659,6 +4735,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
|
|
|
4659
4735
|
createSubscription: createSubscription,
|
|
4660
4736
|
getCurrentValue: getCurrentValue,
|
|
4661
4737
|
setCurrentValue: setCurrentValue,
|
|
4738
|
+
setCurrentValueAsync: setCurrentValueAsync,
|
|
4662
4739
|
updateValue: updateValue
|
|
4663
4740
|
});
|
|
4664
4741
|
|
|
@@ -6283,7 +6360,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6283
6360
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6284
6361
|
};
|
|
6285
6362
|
|
|
6286
|
-
const version = "1.
|
|
6363
|
+
const version = "1.51.0";
|
|
6287
6364
|
const [major, minor, patch] = version.split('.');
|
|
6288
6365
|
let sdkInfo = {
|
|
6289
6366
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -6436,6 +6513,31 @@ const isSafari = () => {
|
|
|
6436
6513
|
return false;
|
|
6437
6514
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
|
|
6438
6515
|
};
|
|
6516
|
+
/**
|
|
6517
|
+
* Checks whether the current runtime is a WebKit-engine browser.
|
|
6518
|
+
*
|
|
6519
|
+
* Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
|
|
6520
|
+
* (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
|
|
6521
|
+
* Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
|
|
6522
|
+
* `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
|
|
6523
|
+
* share the underlying WebKit quirks.
|
|
6524
|
+
*
|
|
6525
|
+
* Returns false for desktop Chromium-based browsers (which reuse the
|
|
6526
|
+
* `AppleWebKit/` token in their UA) and Android.
|
|
6527
|
+
*/
|
|
6528
|
+
const isWebKit = () => {
|
|
6529
|
+
if (typeof navigator === 'undefined')
|
|
6530
|
+
return false;
|
|
6531
|
+
const ua = navigator.userAgent || '';
|
|
6532
|
+
if (!/AppleWebKit\//.test(ua))
|
|
6533
|
+
return false;
|
|
6534
|
+
// Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
|
|
6535
|
+
// `Chromium/` markers are only present on desktop Chromium builds
|
|
6536
|
+
// (their iOS counterparts use `CriOS/` instead). `Android` rules out
|
|
6537
|
+
// the mobile Blink stack.
|
|
6538
|
+
const regExp = /Chrome\/|Chromium\/|Android/;
|
|
6539
|
+
return !regExp.test(ua);
|
|
6540
|
+
};
|
|
6439
6541
|
/**
|
|
6440
6542
|
* Checks whether the current browser is Firefox.
|
|
6441
6543
|
*/
|
|
@@ -6479,7 +6581,8 @@ var browsers = /*#__PURE__*/Object.freeze({
|
|
|
6479
6581
|
isChrome: isChrome,
|
|
6480
6582
|
isFirefox: isFirefox,
|
|
6481
6583
|
isSafari: isSafari,
|
|
6482
|
-
isSupportedBrowser: isSupportedBrowser
|
|
6584
|
+
isSupportedBrowser: isSupportedBrowser,
|
|
6585
|
+
isWebKit: isWebKit
|
|
6483
6586
|
});
|
|
6484
6587
|
|
|
6485
6588
|
/**
|
|
@@ -7376,7 +7479,7 @@ class BasePeerConnection {
|
|
|
7376
7479
|
this.on = (event, fn) => {
|
|
7377
7480
|
const getTag = () => this.tag;
|
|
7378
7481
|
this.subscriptions.push(this.dispatcher.on(event, getTag, (e) => {
|
|
7379
|
-
const lockKey =
|
|
7482
|
+
const lockKey = this.eventLockKey(event);
|
|
7380
7483
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
7381
7484
|
if (this.isDisposed)
|
|
7382
7485
|
return;
|
|
@@ -7384,6 +7487,13 @@ class BasePeerConnection {
|
|
|
7384
7487
|
});
|
|
7385
7488
|
}));
|
|
7386
7489
|
};
|
|
7490
|
+
/**
|
|
7491
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
7492
|
+
* dispatcher handler for `event` on this peer connection.
|
|
7493
|
+
*/
|
|
7494
|
+
this.eventLockKey = (event) => {
|
|
7495
|
+
return `pc.${this.lock}.${event}`;
|
|
7496
|
+
};
|
|
7387
7497
|
/**
|
|
7388
7498
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
7389
7499
|
*/
|
|
@@ -7637,7 +7747,7 @@ class BasePeerConnection {
|
|
|
7637
7747
|
/**
|
|
7638
7748
|
* Disposes the `RTCPeerConnection` instance.
|
|
7639
7749
|
*/
|
|
7640
|
-
dispose() {
|
|
7750
|
+
async dispose() {
|
|
7641
7751
|
clearTimeout(this.iceRestartTimeout);
|
|
7642
7752
|
this.iceRestartTimeout = undefined;
|
|
7643
7753
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -7659,6 +7769,7 @@ class BasePeerConnection {
|
|
|
7659
7769
|
pc.removeEventListener('signalingstatechange', this.onSignalingChange);
|
|
7660
7770
|
pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
7661
7771
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
7772
|
+
pc.removeEventListener('connectionstatechange', this.onConnectionStateChange);
|
|
7662
7773
|
this.unsubscribeIceTrickle?.();
|
|
7663
7774
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
7664
7775
|
this.subscriptions = [];
|
|
@@ -7686,8 +7797,14 @@ class TransceiverCache {
|
|
|
7686
7797
|
* Gets the transceiver for the given publish option.
|
|
7687
7798
|
*/
|
|
7688
7799
|
this.get = (publishOption) => {
|
|
7689
|
-
return this.
|
|
7690
|
-
|
|
7800
|
+
return this.getBy(publishOption.id, publishOption.trackType);
|
|
7801
|
+
};
|
|
7802
|
+
/**
|
|
7803
|
+
* Gets the transceiver for the given publish option id and track type.
|
|
7804
|
+
*/
|
|
7805
|
+
this.getBy = (publishOptionId, trackType) => {
|
|
7806
|
+
return this.cache.find((bundle) => bundle.publishOption.id === publishOptionId &&
|
|
7807
|
+
bundle.publishOption.trackType === trackType);
|
|
7691
7808
|
};
|
|
7692
7809
|
/**
|
|
7693
7810
|
* Updates the cached bundle with the given patch.
|
|
@@ -7955,6 +8072,39 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
|
|
|
7955
8072
|
}));
|
|
7956
8073
|
};
|
|
7957
8074
|
|
|
8075
|
+
const toRTCDegradationPreference = (preference) => {
|
|
8076
|
+
switch (preference) {
|
|
8077
|
+
case DegradationPreference.BALANCED:
|
|
8078
|
+
return 'balanced';
|
|
8079
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
8080
|
+
return 'maintain-framerate';
|
|
8081
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
8082
|
+
return 'maintain-resolution';
|
|
8083
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
8084
|
+
// @ts-expect-error not in the typedefs yet
|
|
8085
|
+
return 'maintain-framerate-and-resolution';
|
|
8086
|
+
case DegradationPreference.UNSPECIFIED:
|
|
8087
|
+
return undefined;
|
|
8088
|
+
default:
|
|
8089
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8090
|
+
}
|
|
8091
|
+
};
|
|
8092
|
+
const fromRTCDegradationPreference = (preference) => {
|
|
8093
|
+
switch (preference) {
|
|
8094
|
+
case 'balanced':
|
|
8095
|
+
return DegradationPreference.BALANCED;
|
|
8096
|
+
case 'maintain-framerate':
|
|
8097
|
+
return DegradationPreference.MAINTAIN_FRAMERATE;
|
|
8098
|
+
case 'maintain-resolution':
|
|
8099
|
+
return DegradationPreference.MAINTAIN_RESOLUTION;
|
|
8100
|
+
// @ts-expect-error not in the typedefs yet
|
|
8101
|
+
case 'maintain-framerate-and-resolution':
|
|
8102
|
+
return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
|
|
8103
|
+
default:
|
|
8104
|
+
return DegradationPreference.UNSPECIFIED;
|
|
8105
|
+
}
|
|
8106
|
+
};
|
|
8107
|
+
|
|
7958
8108
|
/**
|
|
7959
8109
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
7960
8110
|
*
|
|
@@ -7988,13 +8138,13 @@ class Publisher extends BasePeerConnection {
|
|
|
7988
8138
|
// create a clone of the track as otherwise the same trackId will
|
|
7989
8139
|
// appear in the SDP in multiple transceivers
|
|
7990
8140
|
const trackToPublish = this.cloneTrack(track);
|
|
7991
|
-
const
|
|
7992
|
-
if (!
|
|
8141
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
8142
|
+
if (!bundle) {
|
|
7993
8143
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
7994
8144
|
}
|
|
7995
8145
|
else {
|
|
7996
|
-
const previousTrack = transceiver.sender.track;
|
|
7997
|
-
await this.updateTransceiver(
|
|
8146
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
8147
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
7998
8148
|
if (!isReactNative()) {
|
|
7999
8149
|
this.stopTrack(previousTrack);
|
|
8000
8150
|
}
|
|
@@ -8016,7 +8166,9 @@ class Publisher extends BasePeerConnection {
|
|
|
8016
8166
|
sendEncodings,
|
|
8017
8167
|
});
|
|
8018
8168
|
const params = transceiver.sender.getParameters();
|
|
8019
|
-
params.degradationPreference =
|
|
8169
|
+
params.degradationPreference =
|
|
8170
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
8171
|
+
'maintain-framerate';
|
|
8020
8172
|
await transceiver.sender.setParameters(params);
|
|
8021
8173
|
const trackType = publishOption.trackType;
|
|
8022
8174
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
@@ -8027,13 +8179,20 @@ class Publisher extends BasePeerConnection {
|
|
|
8027
8179
|
/**
|
|
8028
8180
|
* Updates the transceiver with the given track and track type.
|
|
8029
8181
|
*/
|
|
8030
|
-
this.updateTransceiver = async (
|
|
8182
|
+
this.updateTransceiver = async (bundle, track, options = {}) => {
|
|
8183
|
+
const { transceiver, publishOption } = bundle;
|
|
8184
|
+
const trackType = publishOption.trackType;
|
|
8031
8185
|
const sender = transceiver.sender;
|
|
8032
8186
|
if (sender.track)
|
|
8033
8187
|
this.trackIdToTrackType.delete(sender.track.id);
|
|
8034
8188
|
await sender.replaceTrack(track);
|
|
8035
|
-
if (track)
|
|
8189
|
+
if (track) {
|
|
8036
8190
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
8191
|
+
if (isFirefox() && bundle.videoSender) {
|
|
8192
|
+
// restore the encoding config from the cache, if any
|
|
8193
|
+
await this.changePublishQuality(bundle.videoSender, bundle);
|
|
8194
|
+
}
|
|
8195
|
+
}
|
|
8037
8196
|
if (isAudioTrackType(trackType)) {
|
|
8038
8197
|
await this.updateAudioPublishOptions(trackType, options);
|
|
8039
8198
|
}
|
|
@@ -8093,7 +8252,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8093
8252
|
continue;
|
|
8094
8253
|
// it is safe to stop the track here, it is a clone
|
|
8095
8254
|
this.stopTrack(transceiver.sender.track);
|
|
8096
|
-
await this.updateTransceiver(
|
|
8255
|
+
await this.updateTransceiver(item, null);
|
|
8097
8256
|
}
|
|
8098
8257
|
};
|
|
8099
8258
|
/**
|
|
@@ -8114,35 +8273,74 @@ class Publisher extends BasePeerConnection {
|
|
|
8114
8273
|
return false;
|
|
8115
8274
|
};
|
|
8116
8275
|
/**
|
|
8117
|
-
*
|
|
8276
|
+
* Re-arms the encoder for the given track type by detaching and
|
|
8277
|
+
* reattaching the currently published track on each matching sender.
|
|
8278
|
+
*
|
|
8279
|
+
* Workaround for a WebKit / iOS Safari quirk: after a system audio
|
|
8280
|
+
* session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
|
|
8281
|
+
* can stop producing RTP packets even though the underlying
|
|
8282
|
+
* `MediaStreamTrack` is `live` and `track.muted === false`.
|
|
8283
|
+
* `replaceTrack(null)` followed by `replaceTrack(track)` resets the
|
|
8284
|
+
* sender's encoder pipeline without renegotiation, restoring packet
|
|
8285
|
+
* flow with the same SSRC.
|
|
8286
|
+
*
|
|
8287
|
+
* No-op when nothing is published for the given track type.
|
|
8288
|
+
*
|
|
8289
|
+
* @param trackType the track type to refresh.
|
|
8118
8290
|
*/
|
|
8119
|
-
this.
|
|
8291
|
+
this.refreshTrack = async (trackType) => {
|
|
8120
8292
|
for (const item of this.transceiverCache.items()) {
|
|
8121
|
-
|
|
8122
|
-
if (!trackTypes.includes(publishOption.trackType))
|
|
8293
|
+
if (item.publishOption.trackType !== trackType)
|
|
8123
8294
|
continue;
|
|
8124
|
-
|
|
8295
|
+
const { sender } = item.transceiver;
|
|
8296
|
+
const track = sender.track;
|
|
8297
|
+
if (!track || track.readyState !== 'live')
|
|
8298
|
+
continue;
|
|
8299
|
+
try {
|
|
8300
|
+
await sender.replaceTrack(null);
|
|
8301
|
+
await sender.replaceTrack(track);
|
|
8302
|
+
this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
|
|
8303
|
+
}
|
|
8304
|
+
catch (err) {
|
|
8305
|
+
this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
|
|
8306
|
+
}
|
|
8125
8307
|
}
|
|
8126
8308
|
};
|
|
8309
|
+
/**
|
|
8310
|
+
* Stops the cloned track that is being published to the SFU.
|
|
8311
|
+
*/
|
|
8312
|
+
this.stopTracks = async (...trackTypes) => {
|
|
8313
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8314
|
+
for (const item of this.transceiverCache.items()) {
|
|
8315
|
+
const { publishOption, transceiver } = item;
|
|
8316
|
+
if (!trackTypes.includes(publishOption.trackType))
|
|
8317
|
+
continue;
|
|
8318
|
+
const track = transceiver.sender.track;
|
|
8319
|
+
await this.silenceSenderOnFirefox(item);
|
|
8320
|
+
this.stopTrack(track);
|
|
8321
|
+
}
|
|
8322
|
+
});
|
|
8323
|
+
};
|
|
8127
8324
|
/**
|
|
8128
8325
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
8129
8326
|
*/
|
|
8130
|
-
this.stopAllTracks = () => {
|
|
8131
|
-
|
|
8132
|
-
this.
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8327
|
+
this.stopAllTracks = async () => {
|
|
8328
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8329
|
+
for (const item of this.transceiverCache.items()) {
|
|
8330
|
+
const track = item.transceiver.sender.track;
|
|
8331
|
+
await this.silenceSenderOnFirefox(item);
|
|
8332
|
+
this.stopTrack(track);
|
|
8333
|
+
}
|
|
8334
|
+
for (const track of this.clonedTracks) {
|
|
8335
|
+
this.stopTrack(track);
|
|
8336
|
+
}
|
|
8337
|
+
});
|
|
8137
8338
|
};
|
|
8138
|
-
this.changePublishQuality = async (videoSender) => {
|
|
8139
|
-
const
|
|
8140
|
-
const enabledLayers = layers.filter((l) => l.active);
|
|
8339
|
+
this.changePublishQuality = async (videoSender, bundle) => {
|
|
8340
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
8141
8341
|
const tag = 'Update publish quality:';
|
|
8142
8342
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
8143
|
-
const
|
|
8144
|
-
t.publishOption.trackType === trackType);
|
|
8145
|
-
const sender = transceiverId?.transceiver.sender;
|
|
8343
|
+
const sender = bundle?.transceiver.sender;
|
|
8146
8344
|
if (!sender) {
|
|
8147
8345
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
8148
8346
|
}
|
|
@@ -8150,7 +8348,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8150
8348
|
if (params.encodings.length === 0) {
|
|
8151
8349
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
8152
8350
|
}
|
|
8153
|
-
const codecInUse =
|
|
8351
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
8154
8352
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
8155
8353
|
let changed = false;
|
|
8156
8354
|
for (const encoder of params.encodings) {
|
|
@@ -8190,6 +8388,12 @@ class Publisher extends BasePeerConnection {
|
|
|
8190
8388
|
changed = true;
|
|
8191
8389
|
}
|
|
8192
8390
|
}
|
|
8391
|
+
const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
|
|
8392
|
+
if (degradationPreference &&
|
|
8393
|
+
params.degradationPreference !== degradationPreference) {
|
|
8394
|
+
params.degradationPreference = degradationPreference;
|
|
8395
|
+
changed = true;
|
|
8396
|
+
}
|
|
8193
8397
|
const activeEncoders = params.encodings.filter((e) => e.active);
|
|
8194
8398
|
if (!changed) {
|
|
8195
8399
|
return this.logger.info(`${tag} no change:`, activeEncoders);
|
|
@@ -8344,6 +8548,72 @@ class Publisher extends BasePeerConnection {
|
|
|
8344
8548
|
track.stop();
|
|
8345
8549
|
this.clonedTracks.delete(track);
|
|
8346
8550
|
};
|
|
8551
|
+
/**
|
|
8552
|
+
* Silences a Firefox sender on the wire during unpublish.
|
|
8553
|
+
*
|
|
8554
|
+
* Firefox keeps emitting RTP after track.stop(), but the right lever
|
|
8555
|
+
* differs by track type:
|
|
8556
|
+
* - audio: `replaceTrack(null)` is the only reliable silencer;
|
|
8557
|
+
* `setParameters({encodings:[...active:false]})` does NOT stop
|
|
8558
|
+
* the Opus encoder.
|
|
8559
|
+
* - video: `setParameters({encodings:[...active:false]})` pauses
|
|
8560
|
+
* the encoder; `replaceTrack(null)` does NOT reliably stop the
|
|
8561
|
+
* video encoder. The prior active=true configuration is captured
|
|
8562
|
+
* onto `bundle.videoSender` so `updateTransceiver` can restore
|
|
8563
|
+
* it on the next publish.
|
|
8564
|
+
*
|
|
8565
|
+
* No-op on non-Firefox browsers and during teardown.
|
|
8566
|
+
*/
|
|
8567
|
+
this.silenceSenderOnFirefox = async (bundle) => {
|
|
8568
|
+
if (this.isDisposed || !isFirefox())
|
|
8569
|
+
return;
|
|
8570
|
+
const { transceiver, publishOption } = bundle;
|
|
8571
|
+
if (isAudioTrackType(publishOption.trackType)) {
|
|
8572
|
+
await transceiver.sender.replaceTrack(null).catch((err) => {
|
|
8573
|
+
this.logger.warn('Failed to clear audio sender track', err);
|
|
8574
|
+
});
|
|
8575
|
+
return;
|
|
8576
|
+
}
|
|
8577
|
+
await this.disableAllEncodings(bundle);
|
|
8578
|
+
};
|
|
8579
|
+
this.disableAllEncodings = async (bundle) => {
|
|
8580
|
+
const { transceiver, publishOption } = bundle;
|
|
8581
|
+
const sender = transceiver.sender;
|
|
8582
|
+
const params = sender.getParameters();
|
|
8583
|
+
if (!params.encodings || params.encodings.length === 0)
|
|
8584
|
+
return;
|
|
8585
|
+
if (!bundle.videoSender) {
|
|
8586
|
+
this.transceiverCache.update(publishOption, {
|
|
8587
|
+
videoSender: {
|
|
8588
|
+
trackType: publishOption.trackType,
|
|
8589
|
+
publishOptionId: publishOption.id,
|
|
8590
|
+
codec: publishOption.codec,
|
|
8591
|
+
degradationPreference: fromRTCDegradationPreference(params.degradationPreference),
|
|
8592
|
+
layers: params.encodings.map((e) => ({
|
|
8593
|
+
name: e.rid ?? 'q',
|
|
8594
|
+
active: e.active ?? true,
|
|
8595
|
+
maxBitrate: e.maxBitrate ?? 0,
|
|
8596
|
+
scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
|
|
8597
|
+
maxFramerate: e.maxFramerate ?? 0,
|
|
8598
|
+
// @ts-expect-error scalabilityMode is not in the typedefs yet
|
|
8599
|
+
scalabilityMode: e.scalabilityMode ?? '',
|
|
8600
|
+
})),
|
|
8601
|
+
},
|
|
8602
|
+
});
|
|
8603
|
+
}
|
|
8604
|
+
let changed = false;
|
|
8605
|
+
for (const encoding of params.encodings) {
|
|
8606
|
+
if (encoding.active !== false) {
|
|
8607
|
+
encoding.active = false;
|
|
8608
|
+
changed = true;
|
|
8609
|
+
}
|
|
8610
|
+
}
|
|
8611
|
+
if (!changed)
|
|
8612
|
+
return;
|
|
8613
|
+
await sender.setParameters(params).catch((err) => {
|
|
8614
|
+
this.logger.error('Failed to disable video sender encodings:', err);
|
|
8615
|
+
});
|
|
8616
|
+
};
|
|
8347
8617
|
this.publishOptions = publishOptions;
|
|
8348
8618
|
this.on('iceRestart', (iceRestart) => {
|
|
8349
8619
|
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
|
|
@@ -8352,7 +8622,16 @@ class Publisher extends BasePeerConnection {
|
|
|
8352
8622
|
});
|
|
8353
8623
|
this.on('changePublishQuality', async (event) => {
|
|
8354
8624
|
for (const videoSender of event.videoSenders) {
|
|
8355
|
-
|
|
8625
|
+
// if not publishing, update the encodingConfigCache and don't modify the state.
|
|
8626
|
+
// we'll apply this config on the next publish/unmute.
|
|
8627
|
+
const { trackType, publishOptionId } = videoSender;
|
|
8628
|
+
const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
|
|
8629
|
+
if (bundle) {
|
|
8630
|
+
this.transceiverCache.update(bundle.publishOption, { videoSender });
|
|
8631
|
+
}
|
|
8632
|
+
if (isFirefox() && !this.isPublishing(trackType))
|
|
8633
|
+
continue;
|
|
8634
|
+
await this.changePublishQuality(videoSender, bundle);
|
|
8356
8635
|
}
|
|
8357
8636
|
});
|
|
8358
8637
|
this.on('changePublishOptions', (event) => {
|
|
@@ -8363,13 +8642,48 @@ class Publisher extends BasePeerConnection {
|
|
|
8363
8642
|
/**
|
|
8364
8643
|
* Disposes this Publisher instance.
|
|
8365
8644
|
*/
|
|
8366
|
-
dispose() {
|
|
8367
|
-
super.dispose();
|
|
8368
|
-
|
|
8645
|
+
async dispose() {
|
|
8646
|
+
await super.dispose();
|
|
8647
|
+
try {
|
|
8648
|
+
await this.stopAllTracks();
|
|
8649
|
+
}
|
|
8650
|
+
catch (err) {
|
|
8651
|
+
this.logger.warn('Failed to stop tracks during dispose', err);
|
|
8652
|
+
}
|
|
8369
8653
|
this.clonedTracks.clear();
|
|
8370
8654
|
}
|
|
8371
8655
|
}
|
|
8372
8656
|
|
|
8657
|
+
/**
|
|
8658
|
+
* Adds unique values to an array.
|
|
8659
|
+
*
|
|
8660
|
+
* @param arr the array to add to.
|
|
8661
|
+
* @param values the values to add.
|
|
8662
|
+
*/
|
|
8663
|
+
const pushToIfMissing = (arr, ...values) => {
|
|
8664
|
+
for (const v of values) {
|
|
8665
|
+
if (!arr.includes(v)) {
|
|
8666
|
+
arr.push(v);
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
return arr;
|
|
8670
|
+
};
|
|
8671
|
+
/**
|
|
8672
|
+
* Removes values from an array if they are present.
|
|
8673
|
+
*
|
|
8674
|
+
* @param arr the array to remove from.
|
|
8675
|
+
* @param values the values to remove.
|
|
8676
|
+
*/
|
|
8677
|
+
const removeFromIfPresent = (arr, ...values) => {
|
|
8678
|
+
for (const v of values) {
|
|
8679
|
+
const index = arr.indexOf(v);
|
|
8680
|
+
if (index !== -1) {
|
|
8681
|
+
arr.splice(index, 1);
|
|
8682
|
+
}
|
|
8683
|
+
}
|
|
8684
|
+
return arr;
|
|
8685
|
+
};
|
|
8686
|
+
|
|
8373
8687
|
/**
|
|
8374
8688
|
* A wrapper around the `RTCPeerConnection` that handles the incoming
|
|
8375
8689
|
* media streams from the SFU.
|
|
@@ -8411,27 +8725,34 @@ class Subscriber extends BasePeerConnection {
|
|
|
8411
8725
|
}
|
|
8412
8726
|
};
|
|
8413
8727
|
this.handleOnTrack = (e) => {
|
|
8414
|
-
const
|
|
8728
|
+
const { streams, track } = e;
|
|
8729
|
+
const [primaryStream] = streams;
|
|
8415
8730
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
8416
8731
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
8417
8732
|
const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8418
|
-
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
8733
|
+
this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
|
|
8734
|
+
const trackType = toTrackType(rawTrackType);
|
|
8735
|
+
if (!trackType) {
|
|
8736
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8737
|
+
}
|
|
8419
8738
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
8420
|
-
|
|
8739
|
+
track.addEventListener('mute', () => {
|
|
8421
8740
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
8741
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8422
8742
|
});
|
|
8423
|
-
|
|
8743
|
+
track.addEventListener('unmute', () => {
|
|
8424
8744
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
8745
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8425
8746
|
});
|
|
8426
|
-
|
|
8747
|
+
track.addEventListener('ended', () => {
|
|
8427
8748
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
8749
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
8428
8750
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
8429
8751
|
});
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
8752
|
+
if (track.muted) {
|
|
8753
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
8433
8754
|
}
|
|
8434
|
-
this.trackIdToTrackType.set(
|
|
8755
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
8435
8756
|
if (!participantToUpdate) {
|
|
8436
8757
|
this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
|
|
8437
8758
|
this.state.registerOrphanedTrack({
|
|
@@ -8457,13 +8778,30 @@ class Subscriber extends BasePeerConnection {
|
|
|
8457
8778
|
});
|
|
8458
8779
|
// now, dispose the previous stream if it exists
|
|
8459
8780
|
if (previousStream) {
|
|
8460
|
-
this.logger.info(`[onTrack]: Cleaning up previous remote ${
|
|
8781
|
+
this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
|
|
8461
8782
|
previousStream.getTracks().forEach((t) => {
|
|
8462
8783
|
t.stop();
|
|
8463
8784
|
previousStream.removeTrack(t);
|
|
8464
8785
|
});
|
|
8465
8786
|
}
|
|
8466
8787
|
};
|
|
8788
|
+
this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
|
|
8789
|
+
if (trackType !== TrackType.AUDIO)
|
|
8790
|
+
return;
|
|
8791
|
+
const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
|
|
8792
|
+
if (!target)
|
|
8793
|
+
return;
|
|
8794
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
8795
|
+
const current = p.interruptedTracks ?? [];
|
|
8796
|
+
const has = current.includes(trackType);
|
|
8797
|
+
if (interrupted === has)
|
|
8798
|
+
return {};
|
|
8799
|
+
const next = interrupted
|
|
8800
|
+
? pushToIfMissing([...current], trackType)
|
|
8801
|
+
: removeFromIfPresent([...current], trackType);
|
|
8802
|
+
return { interruptedTracks: next };
|
|
8803
|
+
});
|
|
8804
|
+
};
|
|
8467
8805
|
this.negotiate = async (subscriberOffer) => {
|
|
8468
8806
|
await this.pc.setRemoteDescription({
|
|
8469
8807
|
type: 'offer',
|
|
@@ -9186,36 +9524,6 @@ const watchCallGrantsUpdated = (state) => {
|
|
|
9186
9524
|
};
|
|
9187
9525
|
};
|
|
9188
9526
|
|
|
9189
|
-
/**
|
|
9190
|
-
* Adds unique values to an array.
|
|
9191
|
-
*
|
|
9192
|
-
* @param arr the array to add to.
|
|
9193
|
-
* @param values the values to add.
|
|
9194
|
-
*/
|
|
9195
|
-
const pushToIfMissing = (arr, ...values) => {
|
|
9196
|
-
for (const v of values) {
|
|
9197
|
-
if (!arr.includes(v)) {
|
|
9198
|
-
arr.push(v);
|
|
9199
|
-
}
|
|
9200
|
-
}
|
|
9201
|
-
return arr;
|
|
9202
|
-
};
|
|
9203
|
-
/**
|
|
9204
|
-
* Removes values from an array if they are present.
|
|
9205
|
-
*
|
|
9206
|
-
* @param arr the array to remove from.
|
|
9207
|
-
* @param values the values to remove.
|
|
9208
|
-
*/
|
|
9209
|
-
const removeFromIfPresent = (arr, ...values) => {
|
|
9210
|
-
for (const v of values) {
|
|
9211
|
-
const index = arr.indexOf(v);
|
|
9212
|
-
if (index !== -1) {
|
|
9213
|
-
arr.splice(index, 1);
|
|
9214
|
-
}
|
|
9215
|
-
}
|
|
9216
|
-
return arr;
|
|
9217
|
-
};
|
|
9218
|
-
|
|
9219
9527
|
const watchConnectionQualityChanged = (dispatcher, state) => {
|
|
9220
9528
|
return dispatcher.on('connectionQualityChanged', '*', (e) => {
|
|
9221
9529
|
const { connectionQualityUpdates } = e;
|
|
@@ -9548,140 +9856,54 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
9548
9856
|
};
|
|
9549
9857
|
};
|
|
9550
9858
|
|
|
9551
|
-
const
|
|
9552
|
-
|
|
9553
|
-
|
|
9859
|
+
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9860
|
+
/**
|
|
9861
|
+
* Tracks audio element bindings and periodically warns about
|
|
9862
|
+
* remote participants whose audio streams have no bound element.
|
|
9863
|
+
*/
|
|
9864
|
+
class AudioBindingsWatchdog {
|
|
9865
|
+
constructor(state, tracer) {
|
|
9866
|
+
this.bindings = new Map();
|
|
9867
|
+
this.enabled = true;
|
|
9868
|
+
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9554
9869
|
/**
|
|
9555
|
-
*
|
|
9870
|
+
* Registers an audio element binding for the given session and track type.
|
|
9871
|
+
* Warns if a different element is already bound to the same key.
|
|
9556
9872
|
*/
|
|
9557
|
-
this.
|
|
9873
|
+
this.register = (element, sessionId, trackType) => {
|
|
9874
|
+
const key = toBindingKey(sessionId, trackType);
|
|
9875
|
+
const existing = this.bindings.get(key);
|
|
9876
|
+
if (existing && existing !== element) {
|
|
9877
|
+
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9878
|
+
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9879
|
+
}
|
|
9880
|
+
this.bindings.set(key, element);
|
|
9881
|
+
};
|
|
9558
9882
|
/**
|
|
9559
|
-
*
|
|
9883
|
+
* Removes the audio element binding for the given session and track type.
|
|
9560
9884
|
*/
|
|
9561
|
-
this.
|
|
9562
|
-
|
|
9563
|
-
|
|
9885
|
+
this.unregister = (sessionId, trackType) => {
|
|
9886
|
+
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9887
|
+
};
|
|
9564
9888
|
/**
|
|
9565
|
-
*
|
|
9889
|
+
* Enables or disables the watchdog.
|
|
9890
|
+
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9566
9891
|
*/
|
|
9567
|
-
this.
|
|
9892
|
+
this.setEnabled = (enabled) => {
|
|
9893
|
+
this.enabled = enabled;
|
|
9894
|
+
if (enabled) {
|
|
9895
|
+
this.start();
|
|
9896
|
+
}
|
|
9897
|
+
else {
|
|
9898
|
+
this.stop();
|
|
9899
|
+
}
|
|
9900
|
+
};
|
|
9568
9901
|
/**
|
|
9569
|
-
*
|
|
9570
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9571
|
-
*
|
|
9572
|
-
* @param viewportElement
|
|
9573
|
-
* @param options
|
|
9574
|
-
* @returns Unobserve
|
|
9575
|
-
*/
|
|
9576
|
-
this.setViewport = (viewportElement, options) => {
|
|
9577
|
-
const cleanup = () => {
|
|
9578
|
-
this.observer?.disconnect();
|
|
9579
|
-
this.observer = null;
|
|
9580
|
-
this.elementHandlerMap.clear();
|
|
9581
|
-
};
|
|
9582
|
-
this.observer = new IntersectionObserver((entries) => {
|
|
9583
|
-
entries.forEach((entry) => {
|
|
9584
|
-
const handler = this.elementHandlerMap.get(entry.target);
|
|
9585
|
-
handler?.(entry);
|
|
9586
|
-
});
|
|
9587
|
-
}, {
|
|
9588
|
-
root: viewportElement,
|
|
9589
|
-
...options,
|
|
9590
|
-
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
9591
|
-
});
|
|
9592
|
-
if (this.queueSet.size) {
|
|
9593
|
-
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
9594
|
-
// check if element which requested observation is
|
|
9595
|
-
// a child of a viewport element, skip if isn't
|
|
9596
|
-
if (!viewportElement.contains(queueElement))
|
|
9597
|
-
return;
|
|
9598
|
-
this.observer.observe(queueElement);
|
|
9599
|
-
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
9600
|
-
});
|
|
9601
|
-
this.queueSet.clear();
|
|
9602
|
-
}
|
|
9603
|
-
return cleanup;
|
|
9604
|
-
};
|
|
9605
|
-
/**
|
|
9606
|
-
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
9607
|
-
* detects a possible change in element's visibility within specified viewport, returns
|
|
9608
|
-
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
9609
|
-
*
|
|
9610
|
-
* @param element
|
|
9611
|
-
* @param handler
|
|
9612
|
-
* @returns Unobserve
|
|
9613
|
-
*/
|
|
9614
|
-
this.observe = (element, handler) => {
|
|
9615
|
-
const queueItem = [element, handler];
|
|
9616
|
-
const cleanup = () => {
|
|
9617
|
-
this.elementHandlerMap.delete(element);
|
|
9618
|
-
this.observer?.unobserve(element);
|
|
9619
|
-
this.queueSet.delete(queueItem);
|
|
9620
|
-
};
|
|
9621
|
-
if (this.elementHandlerMap.has(element))
|
|
9622
|
-
return cleanup;
|
|
9623
|
-
if (!this.observer) {
|
|
9624
|
-
this.queueSet.add(queueItem);
|
|
9625
|
-
return cleanup;
|
|
9626
|
-
}
|
|
9627
|
-
if (this.observer.root.contains(element)) {
|
|
9628
|
-
this.elementHandlerMap.set(element, handler);
|
|
9629
|
-
this.observer.observe(element);
|
|
9630
|
-
}
|
|
9631
|
-
return cleanup;
|
|
9632
|
-
};
|
|
9633
|
-
}
|
|
9634
|
-
}
|
|
9635
|
-
|
|
9636
|
-
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
9637
|
-
/**
|
|
9638
|
-
* Tracks audio element bindings and periodically warns about
|
|
9639
|
-
* remote participants whose audio streams have no bound element.
|
|
9640
|
-
*/
|
|
9641
|
-
class AudioBindingsWatchdog {
|
|
9642
|
-
constructor(state, tracer) {
|
|
9643
|
-
this.state = state;
|
|
9644
|
-
this.tracer = tracer;
|
|
9645
|
-
this.bindings = new Map();
|
|
9646
|
-
this.enabled = true;
|
|
9647
|
-
this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
|
|
9648
|
-
/**
|
|
9649
|
-
* Registers an audio element binding for the given session and track type.
|
|
9650
|
-
* Warns if a different element is already bound to the same key.
|
|
9651
|
-
*/
|
|
9652
|
-
this.register = (audioElement, sessionId, trackType) => {
|
|
9653
|
-
const key = toBindingKey(sessionId, trackType);
|
|
9654
|
-
const existing = this.bindings.get(key);
|
|
9655
|
-
if (existing && existing !== audioElement) {
|
|
9656
|
-
this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
|
|
9657
|
-
this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
|
|
9658
|
-
}
|
|
9659
|
-
this.bindings.set(key, audioElement);
|
|
9660
|
-
};
|
|
9661
|
-
/**
|
|
9662
|
-
* Removes the audio element binding for the given session and track type.
|
|
9663
|
-
*/
|
|
9664
|
-
this.unregister = (sessionId, trackType) => {
|
|
9665
|
-
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
9666
|
-
};
|
|
9667
|
-
/**
|
|
9668
|
-
* Enables or disables the watchdog.
|
|
9669
|
-
* When disabled, the periodic check stops but bindings are still tracked.
|
|
9670
|
-
*/
|
|
9671
|
-
this.setEnabled = (enabled) => {
|
|
9672
|
-
this.enabled = enabled;
|
|
9673
|
-
if (enabled) {
|
|
9674
|
-
this.start();
|
|
9675
|
-
}
|
|
9676
|
-
else {
|
|
9677
|
-
this.stop();
|
|
9678
|
-
}
|
|
9679
|
-
};
|
|
9680
|
-
/**
|
|
9681
|
-
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9902
|
+
* Stops the watchdog and unsubscribes from callingState changes.
|
|
9682
9903
|
*/
|
|
9683
9904
|
this.dispose = () => {
|
|
9684
9905
|
this.stop();
|
|
9906
|
+
this.bindings.clear();
|
|
9685
9907
|
this.unsubscribeCallingState();
|
|
9686
9908
|
};
|
|
9687
9909
|
this.start = () => {
|
|
@@ -9713,6 +9935,8 @@ class AudioBindingsWatchdog {
|
|
|
9713
9935
|
this.stop = () => {
|
|
9714
9936
|
clearInterval(this.watchdogInterval);
|
|
9715
9937
|
};
|
|
9938
|
+
this.tracer = tracer;
|
|
9939
|
+
this.state = state;
|
|
9716
9940
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
9717
9941
|
if (!this.enabled)
|
|
9718
9942
|
return;
|
|
@@ -9726,61 +9950,97 @@ class AudioBindingsWatchdog {
|
|
|
9726
9950
|
}
|
|
9727
9951
|
}
|
|
9728
9952
|
|
|
9729
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
9730
|
-
videoTrack: VisibilityState.UNKNOWN,
|
|
9731
|
-
screenShareTrack: VisibilityState.UNKNOWN,
|
|
9732
|
-
};
|
|
9733
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
9734
9953
|
/**
|
|
9735
|
-
*
|
|
9736
|
-
*
|
|
9737
|
-
* - binding video elements to session ids
|
|
9738
|
-
* - binding audio elements to session ids
|
|
9739
|
-
* - tracking element visibility
|
|
9740
|
-
* - updating subscriptions based on viewport visibility
|
|
9741
|
-
* - updating subscriptions based on video element dimensions
|
|
9742
|
-
* - updating subscriptions based on published tracks
|
|
9954
|
+
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9743
9955
|
*/
|
|
9744
|
-
class
|
|
9745
|
-
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
constructor(callState, speaker, tracer) {
|
|
9956
|
+
class BlockedAudioTracker {
|
|
9957
|
+
constructor(tracer) {
|
|
9958
|
+
this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
9959
|
+
this.blockedElementsSubject = new BehaviorSubject(new Set());
|
|
9749
9960
|
/**
|
|
9750
|
-
*
|
|
9961
|
+
* Whether the browser's autoplay policy is blocking audio playback.
|
|
9962
|
+
* Will be `true` when at least one audio element is currently blocked.
|
|
9963
|
+
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
9751
9964
|
*/
|
|
9752
|
-
this.
|
|
9753
|
-
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
9754
|
-
this.useWebAudio = false;
|
|
9755
|
-
this.pendingSubscriptionsUpdate = null;
|
|
9965
|
+
this.autoplayBlocked$ = this.blockedElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
|
|
9756
9966
|
/**
|
|
9757
|
-
*
|
|
9758
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
9967
|
+
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
9759
9968
|
*/
|
|
9760
|
-
this.
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
this.addBlockedAudioElement = (audioElement) => {
|
|
9768
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
9769
|
-
const next = new Set(elements);
|
|
9770
|
-
next.add(audioElement);
|
|
9771
|
-
return next;
|
|
9969
|
+
this.markBlocked = (audioElement, blocked) => {
|
|
9970
|
+
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
9971
|
+
if (blocked)
|
|
9972
|
+
elements.add(audioElement);
|
|
9973
|
+
else
|
|
9974
|
+
elements.delete(audioElement);
|
|
9975
|
+
return elements;
|
|
9772
9976
|
});
|
|
9773
9977
|
};
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
|
|
9778
|
-
|
|
9978
|
+
/**
|
|
9979
|
+
* Returns whether the given audio element is currently flagged as blocked
|
|
9980
|
+
* by the browser's autoplay policy.
|
|
9981
|
+
*/
|
|
9982
|
+
this.isBlocked = (audioElement) => {
|
|
9983
|
+
return this.blockedElementsSubject.getValue().has(audioElement);
|
|
9984
|
+
};
|
|
9985
|
+
/**
|
|
9986
|
+
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
9987
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
9988
|
+
*/
|
|
9989
|
+
this.resumeAudio = async () => {
|
|
9990
|
+
this.tracer.trace('resumeAudio', null);
|
|
9991
|
+
await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
|
|
9992
|
+
await Promise.all(Array.from(elements, async (element) => {
|
|
9993
|
+
try {
|
|
9994
|
+
if (element.srcObject)
|
|
9995
|
+
await timeboxed([element.play()], 2000);
|
|
9996
|
+
elements.delete(element);
|
|
9997
|
+
}
|
|
9998
|
+
catch (err) {
|
|
9999
|
+
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
10000
|
+
}
|
|
10001
|
+
}));
|
|
10002
|
+
return elements;
|
|
9779
10003
|
});
|
|
9780
10004
|
};
|
|
9781
|
-
this.
|
|
9782
|
-
|
|
9783
|
-
|
|
10005
|
+
this.tracer = tracer;
|
|
10006
|
+
}
|
|
10007
|
+
}
|
|
10008
|
+
|
|
10009
|
+
/** Symbol key for the "applies to all participants" override slot. */
|
|
10010
|
+
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
10011
|
+
/**
|
|
10012
|
+
* Owns the SFU-side video-subscription machinery for a `Call`:
|
|
10013
|
+
*
|
|
10014
|
+
* - Holds the per-session / global override state in a
|
|
10015
|
+
* `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
|
|
10016
|
+
* - Derives the SFU subscription list from `CallState` participants +
|
|
10017
|
+
* current overrides via the `subscriptions` getter.
|
|
10018
|
+
* - Debounces and pushes the list to the SFU through
|
|
10019
|
+
* `sfuClient.updateSubscriptions`.
|
|
10020
|
+
* - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
|
|
10021
|
+
* the override state for React hooks.
|
|
10022
|
+
*
|
|
10023
|
+
* Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
|
|
10024
|
+
* `DynascaleManager.bindVideoElement` triggers `apply()` on every
|
|
10025
|
+
* dimension / visibility change.
|
|
10026
|
+
*/
|
|
10027
|
+
class TrackSubscriptionManager {
|
|
10028
|
+
/**
|
|
10029
|
+
* Constructs new TrackSubscriptionManager instance.
|
|
10030
|
+
*
|
|
10031
|
+
* @param callState the call state.
|
|
10032
|
+
* @param tracer the tracer to use.
|
|
10033
|
+
*/
|
|
10034
|
+
constructor(callState, tracer) {
|
|
10035
|
+
this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
|
|
10036
|
+
this.pendingUpdate = null;
|
|
10037
|
+
this.overridesSubject = new BehaviorSubject({});
|
|
10038
|
+
this.overrides$ = this.overridesSubject.asObservable();
|
|
10039
|
+
/**
|
|
10040
|
+
* Consumer-friendly projection of the override state. Used by the
|
|
10041
|
+
* `useIncomingVideoSettings()` React hook.
|
|
10042
|
+
*/
|
|
10043
|
+
this.incomingVideoSettings$ = this.overrides$.pipe(map((overrides) => {
|
|
9784
10044
|
const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
|
|
9785
10045
|
return {
|
|
9786
10046
|
enabled: globalSettings?.enabled !== false,
|
|
@@ -9802,106 +10062,255 @@ class DynascaleManager {
|
|
|
9802
10062
|
};
|
|
9803
10063
|
}), shareReplay(1));
|
|
9804
10064
|
/**
|
|
9805
|
-
*
|
|
10065
|
+
* Sets the SFU client used by `apply()` to push subscription updates.
|
|
10066
|
+
* Called by the owner on call join; cleared on leave.
|
|
9806
10067
|
*/
|
|
9807
|
-
this.
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
|
|
9813
|
-
|
|
9814
|
-
if (
|
|
9815
|
-
|
|
9816
|
-
|
|
9817
|
-
this.audioContext = undefined;
|
|
10068
|
+
this.setSfuClient = (sfuClient) => {
|
|
10069
|
+
this.sfuClient = sfuClient;
|
|
10070
|
+
};
|
|
10071
|
+
/**
|
|
10072
|
+
* Cancels any pending debounced subscription push. Idempotent.
|
|
10073
|
+
*/
|
|
10074
|
+
this.dispose = () => {
|
|
10075
|
+
if (this.pendingUpdate) {
|
|
10076
|
+
clearTimeout(this.pendingUpdate);
|
|
10077
|
+
this.pendingUpdate = null;
|
|
9818
10078
|
}
|
|
9819
10079
|
};
|
|
9820
|
-
|
|
9821
|
-
|
|
9822
|
-
|
|
9823
|
-
|
|
9824
|
-
|
|
10080
|
+
/**
|
|
10081
|
+
* Sets video-subscription overrides. Called by
|
|
10082
|
+
* `Call.setIncomingVideoEnabled` and
|
|
10083
|
+
* `Call.setPreferredIncomingVideoResolution`.
|
|
10084
|
+
*
|
|
10085
|
+
* - `sessionIds` omitted → applies `override` globally (or clears the
|
|
10086
|
+
* global override if `override` is `undefined`).
|
|
10087
|
+
* - `sessionIds` provided → applies `override` to each listed session.
|
|
10088
|
+
*/
|
|
10089
|
+
this.setOverrides = (override, sessionIds) => {
|
|
10090
|
+
this.tracer.trace('setOverrides', [override, sessionIds]);
|
|
9825
10091
|
if (!sessionIds) {
|
|
9826
|
-
return setCurrentValue(this.
|
|
10092
|
+
return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
|
|
9827
10093
|
}
|
|
9828
|
-
return setCurrentValue(this.
|
|
10094
|
+
return setCurrentValue(this.overridesSubject, (overrides) => ({
|
|
9829
10095
|
...overrides,
|
|
9830
10096
|
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
9831
10097
|
}));
|
|
9832
10098
|
};
|
|
9833
|
-
|
|
9834
|
-
|
|
9835
|
-
|
|
10099
|
+
/**
|
|
10100
|
+
* Pushes `subscriptions` to the SFU. Debounced by `debounceType`
|
|
10101
|
+
* (SLOW by default). Multiple rapid calls coalesce into one RPC.
|
|
10102
|
+
* Passing `0` fires synchronously.
|
|
10103
|
+
*/
|
|
10104
|
+
this.apply = (debounceType = DebounceType.SLOW) => {
|
|
10105
|
+
if (this.pendingUpdate) {
|
|
10106
|
+
clearTimeout(this.pendingUpdate);
|
|
9836
10107
|
}
|
|
9837
10108
|
const updateSubscriptions = () => {
|
|
9838
|
-
this.
|
|
10109
|
+
this.pendingUpdate = null;
|
|
9839
10110
|
this.sfuClient
|
|
9840
|
-
?.updateSubscriptions(this.
|
|
10111
|
+
?.updateSubscriptions(this.subscriptions)
|
|
9841
10112
|
.catch((err) => {
|
|
9842
10113
|
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
9843
10114
|
});
|
|
9844
10115
|
};
|
|
9845
10116
|
if (debounceType) {
|
|
9846
|
-
this.
|
|
10117
|
+
this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
|
|
9847
10118
|
}
|
|
9848
10119
|
else {
|
|
9849
10120
|
updateSubscriptions();
|
|
9850
10121
|
}
|
|
9851
10122
|
};
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
9855
|
-
|
|
9856
|
-
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
10123
|
+
this.tracer = tracer;
|
|
10124
|
+
this.callState = callState;
|
|
10125
|
+
}
|
|
10126
|
+
/**
|
|
10127
|
+
* The current SFU subscription list, computed from `CallState`
|
|
10128
|
+
* participants and the override state. Used by:
|
|
10129
|
+
*
|
|
10130
|
+
* - `apply()` to push to the SFU each time the set changes.
|
|
10131
|
+
* - `Call.getReconnectDetails` to include the subscription list in
|
|
10132
|
+
* the reconnect payload.
|
|
10133
|
+
*/
|
|
10134
|
+
get subscriptions() {
|
|
10135
|
+
const subscriptions = [];
|
|
10136
|
+
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
10137
|
+
// and avoid stale data caused by shareReplay with no active subscribers
|
|
10138
|
+
const participants = this.callState.getParticipantsSnapshot();
|
|
10139
|
+
const overrides = this.overridesSubject.getValue();
|
|
10140
|
+
for (const p of participants) {
|
|
10141
|
+
if (p.isLocalParticipant)
|
|
10142
|
+
continue;
|
|
10143
|
+
// NOTE: audio tracks don't have to be requested explicitly
|
|
10144
|
+
// as the SFU will implicitly subscribe us to all of them,
|
|
10145
|
+
// once they become available.
|
|
10146
|
+
if (p.videoDimension && hasVideo(p)) {
|
|
10147
|
+
const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
|
|
10148
|
+
if (override?.enabled !== false) {
|
|
10149
|
+
subscriptions.push({
|
|
10150
|
+
userId: p.userId,
|
|
10151
|
+
sessionId: p.sessionId,
|
|
10152
|
+
trackType: TrackType.VIDEO,
|
|
10153
|
+
dimension: override?.dimension ?? p.videoDimension,
|
|
10154
|
+
});
|
|
10155
|
+
}
|
|
10156
|
+
}
|
|
10157
|
+
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
10158
|
+
subscriptions.push({
|
|
10159
|
+
userId: p.userId,
|
|
10160
|
+
sessionId: p.sessionId,
|
|
10161
|
+
trackType: TrackType.SCREEN_SHARE,
|
|
10162
|
+
dimension: p.screenShareDimension,
|
|
9878
10163
|
});
|
|
10164
|
+
}
|
|
10165
|
+
if (hasScreenShareAudio(p)) {
|
|
10166
|
+
subscriptions.push({
|
|
10167
|
+
userId: p.userId,
|
|
10168
|
+
sessionId: p.sessionId,
|
|
10169
|
+
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
10170
|
+
});
|
|
10171
|
+
}
|
|
10172
|
+
}
|
|
10173
|
+
return subscriptions;
|
|
10174
|
+
}
|
|
10175
|
+
get overrides() {
|
|
10176
|
+
return getCurrentValue(this.overrides$);
|
|
10177
|
+
}
|
|
10178
|
+
}
|
|
10179
|
+
|
|
10180
|
+
/**
|
|
10181
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
10182
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
10183
|
+
*/
|
|
10184
|
+
class MediaPlaybackWatchdog {
|
|
10185
|
+
constructor(opts) {
|
|
10186
|
+
this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
10187
|
+
this.controller = new AbortController();
|
|
10188
|
+
this.attempt = 0;
|
|
10189
|
+
this.disposed = false;
|
|
10190
|
+
this.attach = () => {
|
|
10191
|
+
if (this.disposed)
|
|
10192
|
+
return;
|
|
10193
|
+
const { signal } = this.controller;
|
|
10194
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
10195
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
10196
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
10197
|
+
};
|
|
10198
|
+
this.dispose = () => {
|
|
10199
|
+
if (this.disposed)
|
|
10200
|
+
return;
|
|
10201
|
+
this.disposed = true;
|
|
10202
|
+
this.controller.abort();
|
|
10203
|
+
if (this.pendingTimer)
|
|
10204
|
+
clearTimeout(this.pendingTimer);
|
|
10205
|
+
this.pendingTimer = undefined;
|
|
10206
|
+
};
|
|
10207
|
+
this.onPlaying = () => {
|
|
10208
|
+
if (this.attempt > 0) {
|
|
10209
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
10210
|
+
kind: this.kind,
|
|
10211
|
+
attempts: this.attempt,
|
|
10212
|
+
});
|
|
10213
|
+
}
|
|
10214
|
+
this.attempt = 0;
|
|
10215
|
+
if (this.pendingTimer)
|
|
10216
|
+
clearTimeout(this.pendingTimer);
|
|
10217
|
+
this.pendingTimer = undefined;
|
|
10218
|
+
};
|
|
10219
|
+
this.onPauseOrSuspend = (event) => {
|
|
10220
|
+
if (this.disposed)
|
|
10221
|
+
return;
|
|
10222
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
10223
|
+
kind: this.kind,
|
|
10224
|
+
reason: event.type,
|
|
9879
10225
|
});
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
viewportVisibilityState: {
|
|
9891
|
-
...previousVisibilityState,
|
|
9892
|
-
[trackType]: VisibilityState.UNKNOWN,
|
|
9893
|
-
},
|
|
9894
|
-
};
|
|
10226
|
+
this.scheduleRecovery();
|
|
10227
|
+
};
|
|
10228
|
+
this.scheduleRecovery = () => {
|
|
10229
|
+
if (this.disposed || this.pendingTimer)
|
|
10230
|
+
return;
|
|
10231
|
+
const skipReason = this.computeSkipReason();
|
|
10232
|
+
if (skipReason) {
|
|
10233
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
10234
|
+
kind: this.kind,
|
|
10235
|
+
reason: skipReason,
|
|
9895
10236
|
});
|
|
9896
|
-
|
|
10237
|
+
return;
|
|
10238
|
+
}
|
|
10239
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
10240
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
10241
|
+
};
|
|
10242
|
+
this.computeSkipReason = () => {
|
|
10243
|
+
if (this.disposed)
|
|
10244
|
+
return 'disposed';
|
|
10245
|
+
if (!this.element.srcObject)
|
|
10246
|
+
return 'noSrc';
|
|
10247
|
+
if (this.element.ended)
|
|
10248
|
+
return 'ended';
|
|
10249
|
+
if (this.isBlocked())
|
|
10250
|
+
return 'blocked';
|
|
10251
|
+
const HAVE_CURRENT_DATA = 2;
|
|
10252
|
+
if (this.element.readyState < HAVE_CURRENT_DATA)
|
|
10253
|
+
return 'notReady';
|
|
10254
|
+
if (!this.element.paused)
|
|
10255
|
+
return 'notPaused';
|
|
10256
|
+
};
|
|
10257
|
+
this.attemptPlay = async () => {
|
|
10258
|
+
this.pendingTimer = undefined;
|
|
10259
|
+
if (this.disposed)
|
|
10260
|
+
return;
|
|
10261
|
+
this.attempt += 1;
|
|
10262
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
10263
|
+
kind: this.kind,
|
|
10264
|
+
attempt: this.attempt,
|
|
10265
|
+
});
|
|
10266
|
+
try {
|
|
10267
|
+
await timeboxed([this.element.play()], 2000);
|
|
10268
|
+
}
|
|
10269
|
+
catch (err) {
|
|
10270
|
+
if (this.disposed)
|
|
10271
|
+
return;
|
|
10272
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
10273
|
+
if (this.attempt >= 10) {
|
|
10274
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
10275
|
+
kind: this.kind,
|
|
10276
|
+
attempts: this.attempt,
|
|
10277
|
+
});
|
|
10278
|
+
return;
|
|
10279
|
+
}
|
|
10280
|
+
this.scheduleRecovery();
|
|
10281
|
+
}
|
|
9897
10282
|
};
|
|
10283
|
+
this.element = opts.element;
|
|
10284
|
+
this.kind = opts.kind;
|
|
10285
|
+
this.tracer = opts.tracer;
|
|
10286
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
10287
|
+
this.attach();
|
|
10288
|
+
}
|
|
10289
|
+
}
|
|
10290
|
+
|
|
10291
|
+
/**
|
|
10292
|
+
* A manager class that handles dynascale related tasks like:
|
|
10293
|
+
*
|
|
10294
|
+
* - binding video elements to session ids
|
|
10295
|
+
* - binding audio elements to session ids
|
|
10296
|
+
*/
|
|
10297
|
+
class DynascaleManager {
|
|
10298
|
+
/**
|
|
10299
|
+
* Creates a new DynascaleManager instance.
|
|
10300
|
+
*/
|
|
10301
|
+
constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
|
|
10302
|
+
this.logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
10303
|
+
this.useWebAudio = false;
|
|
9898
10304
|
/**
|
|
9899
|
-
*
|
|
9900
|
-
*
|
|
9901
|
-
* @param element the viewport element.
|
|
10305
|
+
* Closes the audio context if it was created.
|
|
9902
10306
|
*/
|
|
9903
|
-
this.
|
|
9904
|
-
|
|
10307
|
+
this.dispose = async () => {
|
|
10308
|
+
const context = this.audioContext;
|
|
10309
|
+
if (context && context.state !== 'closed') {
|
|
10310
|
+
document.removeEventListener('click', this.resumeAudioContext);
|
|
10311
|
+
await context.close();
|
|
10312
|
+
this.audioContext = undefined;
|
|
10313
|
+
}
|
|
9905
10314
|
};
|
|
9906
10315
|
/**
|
|
9907
10316
|
* Sets whether to use WebAudio API for audio playback.
|
|
@@ -9946,7 +10355,7 @@ class DynascaleManager {
|
|
|
9946
10355
|
this.callState.updateParticipantTracks(trackType, {
|
|
9947
10356
|
[sessionId]: { dimension },
|
|
9948
10357
|
});
|
|
9949
|
-
this.
|
|
10358
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
9950
10359
|
};
|
|
9951
10360
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
9952
10361
|
/**
|
|
@@ -10035,6 +10444,11 @@ class DynascaleManager {
|
|
|
10035
10444
|
// without prior user interaction:
|
|
10036
10445
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
10037
10446
|
videoElement.muted = true;
|
|
10447
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
10448
|
+
element: videoElement,
|
|
10449
|
+
kind: 'video',
|
|
10450
|
+
tracer: this.tracer,
|
|
10451
|
+
});
|
|
10038
10452
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
10039
10453
|
const streamSubscription = participant$
|
|
10040
10454
|
.pipe(distinctUntilKeyChanged(trackKey))
|
|
@@ -10044,14 +10458,14 @@ class DynascaleManager {
|
|
|
10044
10458
|
return;
|
|
10045
10459
|
videoElement.srcObject = source ?? null;
|
|
10046
10460
|
if (isSafari() || isFirefox()) {
|
|
10047
|
-
setTimeout(() => {
|
|
10461
|
+
setTimeout(async () => {
|
|
10048
10462
|
videoElement.srcObject = source ?? null;
|
|
10049
|
-
|
|
10463
|
+
try {
|
|
10464
|
+
await timeboxed([videoElement.play()], 2000);
|
|
10465
|
+
}
|
|
10466
|
+
catch (e) {
|
|
10050
10467
|
this.logger.warn(`Failed to play stream`, e);
|
|
10051
|
-
}
|
|
10052
|
-
// we add extra delay until we attempt to force-play
|
|
10053
|
-
// the participant's media stream in Firefox and Safari,
|
|
10054
|
-
// as they seem to have some timing issues
|
|
10468
|
+
}
|
|
10055
10469
|
}, 25);
|
|
10056
10470
|
}
|
|
10057
10471
|
});
|
|
@@ -10061,6 +10475,7 @@ class DynascaleManager {
|
|
|
10061
10475
|
publishedTracksSubscription?.unsubscribe();
|
|
10062
10476
|
streamSubscription.unsubscribe();
|
|
10063
10477
|
resizeObserver?.disconnect();
|
|
10478
|
+
playbackWatchdog.dispose();
|
|
10064
10479
|
};
|
|
10065
10480
|
};
|
|
10066
10481
|
/**
|
|
@@ -10078,7 +10493,6 @@ class DynascaleManager {
|
|
|
10078
10493
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
10079
10494
|
if (!participant || participant.isLocalParticipant)
|
|
10080
10495
|
return;
|
|
10081
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
10082
10496
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
10083
10497
|
const updateSinkId = (deviceId, audioContext) => {
|
|
10084
10498
|
if (!deviceId)
|
|
@@ -10097,6 +10511,7 @@ class DynascaleManager {
|
|
|
10097
10511
|
};
|
|
10098
10512
|
let sourceNode = undefined;
|
|
10099
10513
|
let gainNode = undefined;
|
|
10514
|
+
let audioWatchdog = undefined;
|
|
10100
10515
|
const isAudioTrack = trackType === 'audioTrack';
|
|
10101
10516
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
10102
10517
|
const updateMediaStreamSubscription = participant$
|
|
@@ -10107,8 +10522,10 @@ class DynascaleManager {
|
|
|
10107
10522
|
return;
|
|
10108
10523
|
setTimeout(() => {
|
|
10109
10524
|
audioElement.srcObject = source ?? null;
|
|
10525
|
+
audioWatchdog?.dispose();
|
|
10526
|
+
audioWatchdog = undefined;
|
|
10110
10527
|
if (!source) {
|
|
10111
|
-
this.
|
|
10528
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10112
10529
|
return;
|
|
10113
10530
|
}
|
|
10114
10531
|
// Safari has a special quirk that prevents playing audio until the user
|
|
@@ -10136,10 +10553,16 @@ class DynascaleManager {
|
|
|
10136
10553
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
10137
10554
|
if (e.name === 'NotAllowedError') {
|
|
10138
10555
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
10139
|
-
this.
|
|
10556
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
10140
10557
|
}
|
|
10141
10558
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
10142
10559
|
});
|
|
10560
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
10561
|
+
element: audioElement,
|
|
10562
|
+
kind: 'audio',
|
|
10563
|
+
tracer: this.tracer,
|
|
10564
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
10565
|
+
});
|
|
10143
10566
|
}
|
|
10144
10567
|
const { selectedDevice } = this.speaker.state;
|
|
10145
10568
|
if (selectedDevice)
|
|
@@ -10163,38 +10586,17 @@ class DynascaleManager {
|
|
|
10163
10586
|
});
|
|
10164
10587
|
audioElement.autoplay = true;
|
|
10165
10588
|
return () => {
|
|
10166
|
-
this.
|
|
10167
|
-
this.removeBlockedAudioElement(audioElement);
|
|
10589
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
10168
10590
|
sinkIdSubscription?.unsubscribe();
|
|
10169
10591
|
volumeSubscription.unsubscribe();
|
|
10170
10592
|
updateMediaStreamSubscription.unsubscribe();
|
|
10171
10593
|
audioElement.srcObject = null;
|
|
10172
10594
|
sourceNode?.disconnect();
|
|
10173
10595
|
gainNode?.disconnect();
|
|
10596
|
+
audioWatchdog?.dispose();
|
|
10597
|
+
audioWatchdog = undefined;
|
|
10174
10598
|
};
|
|
10175
10599
|
};
|
|
10176
|
-
/**
|
|
10177
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
10178
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
10179
|
-
*
|
|
10180
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
10181
|
-
*/
|
|
10182
|
-
this.resumeAudio = async () => {
|
|
10183
|
-
this.tracer.trace('resumeAudio', null);
|
|
10184
|
-
const blocked = new Set();
|
|
10185
|
-
await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
|
|
10186
|
-
try {
|
|
10187
|
-
if (el.srcObject) {
|
|
10188
|
-
await el.play();
|
|
10189
|
-
}
|
|
10190
|
-
}
|
|
10191
|
-
catch {
|
|
10192
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
10193
|
-
blocked.add(el);
|
|
10194
|
-
}
|
|
10195
|
-
}));
|
|
10196
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
10197
|
-
};
|
|
10198
10600
|
this.getOrCreateAudioContext = () => {
|
|
10199
10601
|
if (!this.useWebAudio)
|
|
10200
10602
|
return;
|
|
@@ -10247,57 +10649,124 @@ class DynascaleManager {
|
|
|
10247
10649
|
this.callState = callState;
|
|
10248
10650
|
this.speaker = speaker;
|
|
10249
10651
|
this.tracer = tracer;
|
|
10250
|
-
|
|
10251
|
-
|
|
10252
|
-
}
|
|
10253
|
-
}
|
|
10254
|
-
setSfuClient(sfuClient) {
|
|
10255
|
-
this.sfuClient = sfuClient;
|
|
10652
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
10653
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
10256
10654
|
}
|
|
10257
|
-
|
|
10258
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
10267
|
-
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
|
|
10271
|
-
|
|
10272
|
-
|
|
10273
|
-
|
|
10274
|
-
|
|
10275
|
-
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
}
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
trackType: TrackType.SCREEN_SHARE,
|
|
10286
|
-
dimension: p.screenShareDimension,
|
|
10655
|
+
}
|
|
10656
|
+
|
|
10657
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
10658
|
+
const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
|
|
10659
|
+
videoTrack: VisibilityState.UNKNOWN,
|
|
10660
|
+
screenShareTrack: VisibilityState.UNKNOWN,
|
|
10661
|
+
};
|
|
10662
|
+
class ViewportTracker {
|
|
10663
|
+
constructor(callState) {
|
|
10664
|
+
this.elementHandlerMap = new Map();
|
|
10665
|
+
this.observer = null;
|
|
10666
|
+
// in React children render before viewport is set, add
|
|
10667
|
+
// them to the queue and observe them once the observer is ready
|
|
10668
|
+
this.queueSet = new Set();
|
|
10669
|
+
/**
|
|
10670
|
+
* Method to set scrollable viewport as root for the IntersectionObserver, returns
|
|
10671
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10672
|
+
*/
|
|
10673
|
+
this.setViewport = (viewportElement, options) => {
|
|
10674
|
+
const cleanup = () => {
|
|
10675
|
+
this.observer?.disconnect();
|
|
10676
|
+
this.observer = null;
|
|
10677
|
+
this.elementHandlerMap.clear();
|
|
10678
|
+
};
|
|
10679
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
10680
|
+
entries.forEach((entry) => {
|
|
10681
|
+
const handler = this.elementHandlerMap.get(entry.target);
|
|
10682
|
+
handler?.(entry);
|
|
10287
10683
|
});
|
|
10288
|
-
}
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
|
|
10293
|
-
|
|
10684
|
+
}, {
|
|
10685
|
+
root: viewportElement,
|
|
10686
|
+
...options,
|
|
10687
|
+
threshold: options?.threshold ?? DEFAULT_THRESHOLD,
|
|
10688
|
+
});
|
|
10689
|
+
if (this.queueSet.size) {
|
|
10690
|
+
this.queueSet.forEach(([queueElement, queueHandler]) => {
|
|
10691
|
+
// check if element which requested observation is
|
|
10692
|
+
// a child of a viewport element, skip if isn't
|
|
10693
|
+
if (!viewportElement.contains(queueElement))
|
|
10694
|
+
return;
|
|
10695
|
+
this.observer.observe(queueElement);
|
|
10696
|
+
this.elementHandlerMap.set(queueElement, queueHandler);
|
|
10294
10697
|
});
|
|
10698
|
+
this.queueSet.clear();
|
|
10295
10699
|
}
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
10299
|
-
|
|
10300
|
-
|
|
10700
|
+
return cleanup;
|
|
10701
|
+
};
|
|
10702
|
+
/**
|
|
10703
|
+
* Method to set element to observe and handler to be triggered whenever IntersectionObserver
|
|
10704
|
+
* detects a possible change in element's visibility within specified viewport, returns
|
|
10705
|
+
* cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
|
|
10706
|
+
*/
|
|
10707
|
+
this.observe = (element, handler) => {
|
|
10708
|
+
const queueItem = [element, handler];
|
|
10709
|
+
const cleanup = () => {
|
|
10710
|
+
this.elementHandlerMap.delete(element);
|
|
10711
|
+
this.observer?.unobserve(element);
|
|
10712
|
+
this.queueSet.delete(queueItem);
|
|
10713
|
+
};
|
|
10714
|
+
if (this.elementHandlerMap.has(element))
|
|
10715
|
+
return cleanup;
|
|
10716
|
+
if (!this.observer) {
|
|
10717
|
+
this.queueSet.add(queueItem);
|
|
10718
|
+
return cleanup;
|
|
10719
|
+
}
|
|
10720
|
+
if (this.observer.root.contains(element)) {
|
|
10721
|
+
this.elementHandlerMap.set(element, handler);
|
|
10722
|
+
this.observer.observe(element);
|
|
10723
|
+
}
|
|
10724
|
+
return cleanup;
|
|
10725
|
+
};
|
|
10726
|
+
/**
|
|
10727
|
+
* Tracks the given element for visibility changes and mirrors the result
|
|
10728
|
+
* into `participant.viewportVisibilityState[trackType]` in `CallState`.
|
|
10729
|
+
* Returns a function that unobserves the element and resets the visibility
|
|
10730
|
+
* state back to `UNKNOWN`.
|
|
10731
|
+
*/
|
|
10732
|
+
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
10733
|
+
const cleanup = this.observe(element, (entry) => {
|
|
10734
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10735
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10736
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10737
|
+
// observer triggers when the element is "moved" to be a fullscreen element
|
|
10738
|
+
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
10739
|
+
const isVisible = entry.isIntersecting || document.fullscreenElement === element
|
|
10740
|
+
? VisibilityState.VISIBLE
|
|
10741
|
+
: VisibilityState.INVISIBLE;
|
|
10742
|
+
return {
|
|
10743
|
+
...participant,
|
|
10744
|
+
viewportVisibilityState: {
|
|
10745
|
+
...previousVisibilityState,
|
|
10746
|
+
[trackType]: isVisible,
|
|
10747
|
+
},
|
|
10748
|
+
};
|
|
10749
|
+
});
|
|
10750
|
+
});
|
|
10751
|
+
return () => {
|
|
10752
|
+
cleanup();
|
|
10753
|
+
// reset visibility state to UNKNOWN upon cleanup
|
|
10754
|
+
// so that the layouts that are not actively observed
|
|
10755
|
+
// can still function normally (runtime layout switching)
|
|
10756
|
+
this.callState.updateParticipant(sessionId, (participant) => {
|
|
10757
|
+
const previousVisibilityState = participant.viewportVisibilityState ??
|
|
10758
|
+
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
10759
|
+
return {
|
|
10760
|
+
...participant,
|
|
10761
|
+
viewportVisibilityState: {
|
|
10762
|
+
...previousVisibilityState,
|
|
10763
|
+
[trackType]: VisibilityState.UNKNOWN,
|
|
10764
|
+
},
|
|
10765
|
+
};
|
|
10766
|
+
});
|
|
10767
|
+
};
|
|
10768
|
+
};
|
|
10769
|
+
this.callState = callState;
|
|
10301
10770
|
}
|
|
10302
10771
|
}
|
|
10303
10772
|
|
|
@@ -10964,8 +11433,8 @@ const normalize = (options) => {
|
|
|
10964
11433
|
: false,
|
|
10965
11434
|
};
|
|
10966
11435
|
};
|
|
10967
|
-
const createSyntheticDevice = (deviceId, kind) => {
|
|
10968
|
-
return { deviceId, kind, label
|
|
11436
|
+
const createSyntheticDevice = (deviceId, kind, label = '') => {
|
|
11437
|
+
return { deviceId, kind, label, groupId: '' };
|
|
10969
11438
|
};
|
|
10970
11439
|
const readPreferences = (storageKey) => {
|
|
10971
11440
|
try {
|
|
@@ -11015,9 +11484,12 @@ class DeviceManager {
|
|
|
11015
11484
|
*/
|
|
11016
11485
|
this.stopOnLeave = true;
|
|
11017
11486
|
this.subscriptions = [];
|
|
11487
|
+
this.currentStreamCleanups = [];
|
|
11018
11488
|
this.areSubscriptionsSetUp = false;
|
|
11019
11489
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
11020
11490
|
this.filters = [];
|
|
11491
|
+
this.virtualDevicesSubject = new BehaviorSubject([]);
|
|
11492
|
+
this.virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
|
|
11021
11493
|
this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
|
|
11022
11494
|
this.filterRegistrationConcurrencyTag = Symbol('filterRegistrationConcurrencyTag');
|
|
11023
11495
|
/**
|
|
@@ -11026,9 +11498,30 @@ class DeviceManager {
|
|
|
11026
11498
|
* @internal
|
|
11027
11499
|
*/
|
|
11028
11500
|
this.dispose = () => {
|
|
11501
|
+
this.runCurrentStreamCleanups();
|
|
11029
11502
|
this.subscriptions.forEach((s) => s());
|
|
11030
11503
|
this.subscriptions = [];
|
|
11031
11504
|
this.areSubscriptionsSetUp = false;
|
|
11505
|
+
this.virtualDevicesSubject.next([]);
|
|
11506
|
+
};
|
|
11507
|
+
this.runCurrentStreamCleanups = () => {
|
|
11508
|
+
this.currentStreamCleanups.forEach((c) => c());
|
|
11509
|
+
this.currentStreamCleanups = [];
|
|
11510
|
+
};
|
|
11511
|
+
this.setLocalInterrupted = (interrupted) => {
|
|
11512
|
+
const localParticipant = this.call.state.localParticipant;
|
|
11513
|
+
if (!localParticipant)
|
|
11514
|
+
return;
|
|
11515
|
+
this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
|
|
11516
|
+
const current = p.interruptedTracks ?? [];
|
|
11517
|
+
const has = current.includes(this.trackType);
|
|
11518
|
+
if (interrupted === has)
|
|
11519
|
+
return {};
|
|
11520
|
+
const next = interrupted
|
|
11521
|
+
? pushToIfMissing([...current], this.trackType)
|
|
11522
|
+
: removeFromIfPresent([...current], this.trackType);
|
|
11523
|
+
return { interruptedTracks: next };
|
|
11524
|
+
});
|
|
11032
11525
|
};
|
|
11033
11526
|
this.call = call;
|
|
11034
11527
|
this.state = state;
|
|
@@ -11061,14 +11554,100 @@ class DeviceManager {
|
|
|
11061
11554
|
}
|
|
11062
11555
|
}
|
|
11063
11556
|
/**
|
|
11064
|
-
* Lists the available audio/video devices
|
|
11557
|
+
* Lists the available audio/video devices
|
|
11558
|
+
*
|
|
11559
|
+
* Note: It prompts the user for a permission to use devices (if not already granted)
|
|
11560
|
+
*
|
|
11561
|
+
* @returns an Observable that will be updated if a device is connected or disconnected
|
|
11562
|
+
*/
|
|
11563
|
+
listDevices() {
|
|
11564
|
+
return combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(map(([real, virtual]) => [
|
|
11565
|
+
...real,
|
|
11566
|
+
...virtual.map((d) => createSyntheticDevice(d.deviceId, d.kind, d.label)),
|
|
11567
|
+
]));
|
|
11568
|
+
}
|
|
11569
|
+
/**
|
|
11570
|
+
* Registers a virtual camera or microphone backed by a caller-supplied
|
|
11571
|
+
* stream factory. The device appears in `listDevices()` and can be selected
|
|
11572
|
+
* via `select()` like any real device.
|
|
11065
11573
|
*
|
|
11066
|
-
*
|
|
11574
|
+
* Web only. React Native is not supported.
|
|
11067
11575
|
*
|
|
11068
|
-
*
|
|
11576
|
+
* Only supported for camera and microphone managers; calling on any other
|
|
11577
|
+
* manager throws.
|
|
11069
11578
|
*/
|
|
11070
|
-
|
|
11071
|
-
|
|
11579
|
+
registerVirtualDevice(virtualDevice) {
|
|
11580
|
+
if (isReactNative()) {
|
|
11581
|
+
throw new Error('Virtual devices are not supported on React Native.');
|
|
11582
|
+
}
|
|
11583
|
+
if (this.trackType !== TrackType.AUDIO &&
|
|
11584
|
+
this.trackType !== TrackType.VIDEO) {
|
|
11585
|
+
throw new Error('Virtual devices are only supported for camera and microphone.');
|
|
11586
|
+
}
|
|
11587
|
+
const deviceId = `stream-virtual:${generateUUIDv4()}`;
|
|
11588
|
+
const entry = {
|
|
11589
|
+
deviceId,
|
|
11590
|
+
kind: this.mediaDeviceKind,
|
|
11591
|
+
...virtualDevice,
|
|
11592
|
+
};
|
|
11593
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => [
|
|
11594
|
+
...current,
|
|
11595
|
+
entry,
|
|
11596
|
+
]);
|
|
11597
|
+
return {
|
|
11598
|
+
deviceId: entry.deviceId,
|
|
11599
|
+
unregister: async () => {
|
|
11600
|
+
await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11601
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => current.filter((d) => d !== entry));
|
|
11602
|
+
if (this.activeVirtualSession?.deviceId === deviceId) {
|
|
11603
|
+
await this.stopActiveVirtualSession();
|
|
11604
|
+
}
|
|
11605
|
+
});
|
|
11606
|
+
if (this.state.selectedDevice === deviceId) {
|
|
11607
|
+
await this.statusChangeSettled();
|
|
11608
|
+
await this.disable({ forceStop: true });
|
|
11609
|
+
await this.select(undefined);
|
|
11610
|
+
}
|
|
11611
|
+
},
|
|
11612
|
+
};
|
|
11613
|
+
}
|
|
11614
|
+
sanitizeVirtualStream(stream) {
|
|
11615
|
+
stream.getTracks().forEach((track) => {
|
|
11616
|
+
const originalGetSettings = track.getSettings.bind(track);
|
|
11617
|
+
track.getSettings = () => {
|
|
11618
|
+
const settings = originalGetSettings();
|
|
11619
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11620
|
+
const { deviceId, ...rest } = settings;
|
|
11621
|
+
return rest;
|
|
11622
|
+
};
|
|
11623
|
+
});
|
|
11624
|
+
return stream;
|
|
11625
|
+
}
|
|
11626
|
+
findVirtualDevice(deviceId) {
|
|
11627
|
+
if (!deviceId)
|
|
11628
|
+
return undefined;
|
|
11629
|
+
return getCurrentValue(this.virtualDevicesSubject).find((d) => d.deviceId === deviceId);
|
|
11630
|
+
}
|
|
11631
|
+
async stopActiveVirtualSession() {
|
|
11632
|
+
const session = this.activeVirtualSession;
|
|
11633
|
+
this.activeVirtualSession = undefined;
|
|
11634
|
+
await session?.stop?.();
|
|
11635
|
+
}
|
|
11636
|
+
async getSelectedStream(constraints) {
|
|
11637
|
+
const deviceId = this.state.selectedDevice;
|
|
11638
|
+
if (!deviceId?.startsWith('stream-virtual')) {
|
|
11639
|
+
return this.getStream(constraints);
|
|
11640
|
+
}
|
|
11641
|
+
return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11642
|
+
const virtualDevice = this.findVirtualDevice(deviceId);
|
|
11643
|
+
if (!virtualDevice) {
|
|
11644
|
+
throw new Error(`Virtual device is not registered: ${deviceId}`);
|
|
11645
|
+
}
|
|
11646
|
+
await this.stopActiveVirtualSession();
|
|
11647
|
+
const { stream, stop } = await virtualDevice.getUserMedia(constraints);
|
|
11648
|
+
this.activeVirtualSession = { deviceId, stop };
|
|
11649
|
+
return this.sanitizeVirtualStream(stream);
|
|
11650
|
+
});
|
|
11072
11651
|
}
|
|
11073
11652
|
/**
|
|
11074
11653
|
* Returns `true` when this device is in enabled state.
|
|
@@ -11228,6 +11807,9 @@ class DeviceManager {
|
|
|
11228
11807
|
}
|
|
11229
11808
|
});
|
|
11230
11809
|
}
|
|
11810
|
+
getResolvedConstraints(constraints) {
|
|
11811
|
+
return constraints;
|
|
11812
|
+
}
|
|
11231
11813
|
publishStream(stream, options) {
|
|
11232
11814
|
return this.call.publish(stream, this.trackType, options);
|
|
11233
11815
|
}
|
|
@@ -11248,12 +11830,15 @@ class DeviceManager {
|
|
|
11248
11830
|
this.muteLocalStream(stopTracks);
|
|
11249
11831
|
const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
|
|
11250
11832
|
if (allEnded) {
|
|
11833
|
+
await this.stopActiveVirtualSession();
|
|
11251
11834
|
// @ts-expect-error release() is present in react-native-webrtc
|
|
11252
11835
|
if (typeof mediaStream.release === 'function') {
|
|
11253
11836
|
// @ts-expect-error called to dispose the stream in RN
|
|
11254
11837
|
mediaStream.release();
|
|
11255
11838
|
}
|
|
11839
|
+
this.runCurrentStreamCleanups();
|
|
11256
11840
|
this.state.setMediaStream(undefined, undefined);
|
|
11841
|
+
this.setLocalInterrupted(false);
|
|
11257
11842
|
this.filters.forEach((entry) => entry.stop?.());
|
|
11258
11843
|
}
|
|
11259
11844
|
}
|
|
@@ -11289,20 +11874,24 @@ class DeviceManager {
|
|
|
11289
11874
|
async unmuteStream() {
|
|
11290
11875
|
this.logger.debug('Starting stream');
|
|
11291
11876
|
let stream;
|
|
11292
|
-
let
|
|
11877
|
+
let rootStreamPromise;
|
|
11293
11878
|
if (this.state.mediaStream &&
|
|
11294
11879
|
this.getTracks().every((t) => t.readyState === 'live')) {
|
|
11295
11880
|
stream = this.state.mediaStream;
|
|
11296
11881
|
this.enableTracks();
|
|
11297
11882
|
}
|
|
11298
11883
|
else {
|
|
11884
|
+
// We are about to compose a fresh filter chain and acquire a new
|
|
11885
|
+
// root stream. Drop any listeners bound to the previous root stream
|
|
11886
|
+
// before chainWith below registers new ones for the new chain.
|
|
11887
|
+
this.runCurrentStreamCleanups();
|
|
11299
11888
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11300
|
-
const constraints = {
|
|
11889
|
+
const constraints = this.getResolvedConstraints({
|
|
11301
11890
|
...defaultConstraints,
|
|
11302
11891
|
deviceId: this.state.selectedDevice
|
|
11303
11892
|
? { exact: this.state.selectedDevice }
|
|
11304
11893
|
: undefined,
|
|
11305
|
-
};
|
|
11894
|
+
});
|
|
11306
11895
|
/**
|
|
11307
11896
|
* Chains two media streams together.
|
|
11308
11897
|
*
|
|
@@ -11351,7 +11940,7 @@ class DeviceManager {
|
|
|
11351
11940
|
});
|
|
11352
11941
|
};
|
|
11353
11942
|
parentTrack.addEventListener('ended', handleParentTrackEnded);
|
|
11354
|
-
this.
|
|
11943
|
+
this.currentStreamCleanups.push(() => {
|
|
11355
11944
|
parentTrack.removeEventListener('ended', handleParentTrackEnded);
|
|
11356
11945
|
});
|
|
11357
11946
|
});
|
|
@@ -11359,7 +11948,7 @@ class DeviceManager {
|
|
|
11359
11948
|
};
|
|
11360
11949
|
// the rootStream represents the stream coming from the actual device
|
|
11361
11950
|
// e.g. camera or microphone stream
|
|
11362
|
-
|
|
11951
|
+
rootStreamPromise = this.getSelectedStream(constraints);
|
|
11363
11952
|
// we publish the last MediaStream of the chain
|
|
11364
11953
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11365
11954
|
.then((inputStream) => {
|
|
@@ -11370,42 +11959,70 @@ class DeviceManager {
|
|
|
11370
11959
|
.then(chainWith(parent), (error) => {
|
|
11371
11960
|
this.logger.warn('Filter failed to start and will be ignored', error);
|
|
11372
11961
|
return parent;
|
|
11373
|
-
}),
|
|
11962
|
+
}), rootStreamPromise);
|
|
11374
11963
|
}
|
|
11375
11964
|
if (this.call.state.callingState === CallingState.JOINED) {
|
|
11376
11965
|
await this.publishStream(stream);
|
|
11377
11966
|
}
|
|
11378
11967
|
if (this.state.mediaStream !== stream) {
|
|
11379
|
-
|
|
11380
|
-
|
|
11381
|
-
|
|
11382
|
-
|
|
11383
|
-
this.
|
|
11384
|
-
|
|
11385
|
-
|
|
11386
|
-
|
|
11387
|
-
|
|
11388
|
-
|
|
11389
|
-
|
|
11390
|
-
|
|
11391
|
-
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
this.
|
|
11395
|
-
|
|
11396
|
-
|
|
11397
|
-
|
|
11398
|
-
|
|
11399
|
-
|
|
11400
|
-
|
|
11401
|
-
|
|
11402
|
-
|
|
11403
|
-
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
|
|
11968
|
+
const rootStream = await rootStreamPromise;
|
|
11969
|
+
this.state.setMediaStream(stream, rootStream);
|
|
11970
|
+
if (rootStream) {
|
|
11971
|
+
const handleTrackEnded = async () => {
|
|
11972
|
+
this.setLocalInterrupted(false);
|
|
11973
|
+
await this.statusChangeSettled();
|
|
11974
|
+
if (this.enabled) {
|
|
11975
|
+
this.isTrackStoppedDueToTrackEnd = true;
|
|
11976
|
+
setTimeout(() => {
|
|
11977
|
+
this.isTrackStoppedDueToTrackEnd = false;
|
|
11978
|
+
}, 2000);
|
|
11979
|
+
await this.disable();
|
|
11980
|
+
}
|
|
11981
|
+
};
|
|
11982
|
+
const createTrackMuteHandler = (muted) => () => {
|
|
11983
|
+
this.setLocalInterrupted(muted);
|
|
11984
|
+
// WebKit's RTCRtpSender encoder can stay stalled after an iOS /
|
|
11985
|
+
// macOS audio session interruption even though the track is
|
|
11986
|
+
// unmuted. Re-arm the sender on every unmute for any WebKit
|
|
11987
|
+
// runtime (Safari + plain iOS WKWebViews). Skipped when the
|
|
11988
|
+
// page is hidden because the encoder won't resume until
|
|
11989
|
+
// foreground anyway.
|
|
11990
|
+
if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
|
|
11991
|
+
this.call.refreshPublishedTrack(this.trackType).catch((err) => {
|
|
11992
|
+
this.logger.warn('Failed to refresh track on system unmute', err);
|
|
11993
|
+
});
|
|
11994
|
+
}
|
|
11995
|
+
// report all tracks on mobile, and only Video on desktop browsers
|
|
11996
|
+
if (isMobile() || this.trackType == TrackType.VIDEO) {
|
|
11997
|
+
this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
|
|
11998
|
+
trackType: TrackType[this.trackType],
|
|
11999
|
+
muted,
|
|
12000
|
+
});
|
|
12001
|
+
this.call
|
|
12002
|
+
.notifyTrackMuteState(muted, this.trackType)
|
|
12003
|
+
.catch((err) => {
|
|
12004
|
+
this.logger.warn('Error while notifying track mute state', err);
|
|
12005
|
+
});
|
|
12006
|
+
}
|
|
12007
|
+
};
|
|
12008
|
+
rootStream.getTracks().forEach((track) => {
|
|
12009
|
+
const muteHandler = createTrackMuteHandler(true);
|
|
12010
|
+
const unmuteHandler = createTrackMuteHandler(false);
|
|
12011
|
+
track.addEventListener('mute', muteHandler);
|
|
12012
|
+
track.addEventListener('unmute', unmuteHandler);
|
|
12013
|
+
track.addEventListener('ended', handleTrackEnded);
|
|
12014
|
+
this.currentStreamCleanups.push(() => {
|
|
12015
|
+
track.removeEventListener('mute', muteHandler);
|
|
12016
|
+
track.removeEventListener('unmute', unmuteHandler);
|
|
12017
|
+
track.removeEventListener('ended', handleTrackEnded);
|
|
12018
|
+
});
|
|
11407
12019
|
});
|
|
11408
|
-
|
|
12020
|
+
const initialMuted = rootStream.getTracks().some((t) => t.muted);
|
|
12021
|
+
this.setLocalInterrupted(initialMuted);
|
|
12022
|
+
}
|
|
12023
|
+
else {
|
|
12024
|
+
this.setLocalInterrupted(false);
|
|
12025
|
+
}
|
|
11409
12026
|
}
|
|
11410
12027
|
}
|
|
11411
12028
|
get mediaDeviceKind() {
|
|
@@ -11551,7 +12168,6 @@ class DeviceManagerState {
|
|
|
11551
12168
|
this.defaultConstraintsSubject = new BehaviorSubject(undefined);
|
|
11552
12169
|
/**
|
|
11553
12170
|
* An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
|
|
11554
|
-
*
|
|
11555
12171
|
*/
|
|
11556
12172
|
this.mediaStream$ = this.mediaStreamSubject.asObservable();
|
|
11557
12173
|
/**
|
|
@@ -11649,7 +12265,10 @@ class DeviceManagerState {
|
|
|
11649
12265
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
11650
12266
|
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
11651
12267
|
if (rootStream) {
|
|
11652
|
-
this.
|
|
12268
|
+
const derived = this.getDeviceIdFromStream(rootStream);
|
|
12269
|
+
if (derived) {
|
|
12270
|
+
this.setDevice(derived);
|
|
12271
|
+
}
|
|
11653
12272
|
}
|
|
11654
12273
|
}
|
|
11655
12274
|
/**
|
|
@@ -11862,7 +12481,7 @@ class CameraManager extends DeviceManager {
|
|
|
11862
12481
|
getDevices() {
|
|
11863
12482
|
return getVideoDevices(this.call.tracer);
|
|
11864
12483
|
}
|
|
11865
|
-
|
|
12484
|
+
getResolvedConstraints(constraints) {
|
|
11866
12485
|
constraints.width = this.targetResolution.width;
|
|
11867
12486
|
constraints.height = this.targetResolution.height;
|
|
11868
12487
|
// We can't set both device id and facing mode
|
|
@@ -11873,6 +12492,9 @@ class CameraManager extends DeviceManager {
|
|
|
11873
12492
|
constraints.facingMode =
|
|
11874
12493
|
this.state.direction === 'front' ? 'user' : 'environment';
|
|
11875
12494
|
}
|
|
12495
|
+
return constraints;
|
|
12496
|
+
}
|
|
12497
|
+
getStream(constraints) {
|
|
11876
12498
|
return getVideoStream(constraints, this.call.tracer);
|
|
11877
12499
|
}
|
|
11878
12500
|
}
|
|
@@ -13200,14 +13822,17 @@ class Call {
|
|
|
13200
13822
|
this.sfuStatsReporter?.flush();
|
|
13201
13823
|
this.sfuStatsReporter?.stop();
|
|
13202
13824
|
this.sfuStatsReporter = undefined;
|
|
13203
|
-
this.
|
|
13825
|
+
this.lastStatsOptions = undefined;
|
|
13826
|
+
await this.subscriber?.dispose();
|
|
13204
13827
|
this.subscriber = undefined;
|
|
13205
|
-
this.publisher?.dispose();
|
|
13828
|
+
await this.publisher?.dispose();
|
|
13206
13829
|
this.publisher = undefined;
|
|
13207
13830
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13208
13831
|
this.sfuClient = undefined;
|
|
13209
|
-
this.
|
|
13210
|
-
|
|
13832
|
+
this.trackSubscriptionManager.setSfuClient(undefined);
|
|
13833
|
+
this.trackSubscriptionManager.dispose();
|
|
13834
|
+
this.audioBindingsWatchdog?.dispose();
|
|
13835
|
+
await this.dynascaleManager?.dispose();
|
|
13211
13836
|
this.state.setCallingState(CallingState.LEFT);
|
|
13212
13837
|
this.state.setParticipants([]);
|
|
13213
13838
|
this.state.dispose();
|
|
@@ -13476,15 +14101,17 @@ class Call {
|
|
|
13476
14101
|
const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13477
14102
|
const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
|
|
13478
14103
|
const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
|
|
13479
|
-
let statsOptions = this.
|
|
14104
|
+
let statsOptions = this.lastStatsOptions;
|
|
13480
14105
|
if (!this.credentials ||
|
|
13481
14106
|
!statsOptions ||
|
|
13482
14107
|
performingRejoin ||
|
|
13483
|
-
performingMigration
|
|
14108
|
+
performingMigration ||
|
|
14109
|
+
data?.migrating_from) {
|
|
13484
14110
|
try {
|
|
13485
14111
|
const joinResponse = await this.doJoinRequest(data);
|
|
13486
14112
|
this.credentials = joinResponse.credentials;
|
|
13487
14113
|
statsOptions = joinResponse.stats_options;
|
|
14114
|
+
this.lastStatsOptions = statsOptions;
|
|
13488
14115
|
}
|
|
13489
14116
|
catch (error) {
|
|
13490
14117
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
@@ -13517,7 +14144,7 @@ class Call {
|
|
|
13517
14144
|
: previousSfuClient;
|
|
13518
14145
|
this.sfuClient = sfuClient;
|
|
13519
14146
|
this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
|
|
13520
|
-
this.
|
|
14147
|
+
this.trackSubscriptionManager.setSfuClient(sfuClient);
|
|
13521
14148
|
const clientDetails = await getClientDetails();
|
|
13522
14149
|
// we don't need to send JoinRequest if we are re-using an existing healthy SFU client
|
|
13523
14150
|
if (previousSfuClient !== sfuClient) {
|
|
@@ -13591,7 +14218,7 @@ class Call {
|
|
|
13591
14218
|
}
|
|
13592
14219
|
else {
|
|
13593
14220
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
13594
|
-
this.initPublisherAndSubscriber({
|
|
14221
|
+
await this.initPublisherAndSubscriber({
|
|
13595
14222
|
sfuClient,
|
|
13596
14223
|
connectionConfig,
|
|
13597
14224
|
clientDetails,
|
|
@@ -13652,7 +14279,7 @@ class Call {
|
|
|
13652
14279
|
return {
|
|
13653
14280
|
strategy,
|
|
13654
14281
|
announcedTracks,
|
|
13655
|
-
subscriptions: this.
|
|
14282
|
+
subscriptions: this.trackSubscriptionManager.subscriptions,
|
|
13656
14283
|
reconnectAttempt: this.reconnectAttempts,
|
|
13657
14284
|
fromSfuId: migratingFromSfuId || '',
|
|
13658
14285
|
previousSessionId: performingRejoin ? previousSessionId || '' : '',
|
|
@@ -13736,11 +14363,11 @@ class Call {
|
|
|
13736
14363
|
* Initializes the Publisher and Subscriber Peer Connections.
|
|
13737
14364
|
* @internal
|
|
13738
14365
|
*/
|
|
13739
|
-
this.initPublisherAndSubscriber = (opts) => {
|
|
14366
|
+
this.initPublisherAndSubscriber = async (opts) => {
|
|
13740
14367
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
13741
14368
|
const { enable_rtc_stats: enableTracing } = statsOptions;
|
|
13742
14369
|
if (closePreviousInstances && this.subscriber) {
|
|
13743
|
-
this.subscriber.dispose();
|
|
14370
|
+
await this.subscriber.dispose();
|
|
13744
14371
|
}
|
|
13745
14372
|
const basePeerConnectionOptions = {
|
|
13746
14373
|
sfuClient,
|
|
@@ -13769,7 +14396,7 @@ class Call {
|
|
|
13769
14396
|
const isAnonymous = this.streamClient.user?.type === 'anonymous';
|
|
13770
14397
|
if (!isAnonymous) {
|
|
13771
14398
|
if (closePreviousInstances && this.publisher) {
|
|
13772
|
-
this.publisher.dispose();
|
|
14399
|
+
await this.publisher.dispose();
|
|
13773
14400
|
}
|
|
13774
14401
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
|
|
13775
14402
|
}
|
|
@@ -13872,10 +14499,17 @@ class Call {
|
|
|
13872
14499
|
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
13873
14500
|
*/
|
|
13874
14501
|
this.reconnect = async (strategy, reason) => {
|
|
13875
|
-
if (this.state.callingState === CallingState.
|
|
14502
|
+
if (this.state.callingState === CallingState.JOINING ||
|
|
14503
|
+
this.state.callingState === CallingState.RECONNECTING ||
|
|
13876
14504
|
this.state.callingState === CallingState.MIGRATING ||
|
|
13877
14505
|
this.state.callingState === CallingState.RECONNECTING_FAILED)
|
|
13878
14506
|
return;
|
|
14507
|
+
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
14508
|
+
// running for this Call, that entry will resolve whatever broke;
|
|
14509
|
+
// queueing more entries just replays the full REJOIN cycle (one extra
|
|
14510
|
+
// `POST /join` per entry) once the call is already healthy again.
|
|
14511
|
+
if (hasPending(this.reconnectConcurrencyTag))
|
|
14512
|
+
return;
|
|
13879
14513
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
13880
14514
|
const reconnectStartTime = Date.now();
|
|
13881
14515
|
this.reconnectStrategy = strategy;
|
|
@@ -14080,8 +14714,8 @@ class Call {
|
|
|
14080
14714
|
this.state.setCallingState(CallingState.JOINED);
|
|
14081
14715
|
}
|
|
14082
14716
|
finally {
|
|
14083
|
-
currentSubscriber?.dispose();
|
|
14084
|
-
currentPublisher?.dispose();
|
|
14717
|
+
await currentSubscriber?.dispose();
|
|
14718
|
+
await currentPublisher?.dispose();
|
|
14085
14719
|
// and close the previous SFU client, without specifying close code
|
|
14086
14720
|
currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
|
|
14087
14721
|
}
|
|
@@ -14199,7 +14833,7 @@ class Call {
|
|
|
14199
14833
|
const { remoteParticipants } = this.state;
|
|
14200
14834
|
if (remoteParticipants.length <= 0)
|
|
14201
14835
|
return;
|
|
14202
|
-
this.
|
|
14836
|
+
this.trackSubscriptionManager.apply(undefined);
|
|
14203
14837
|
};
|
|
14204
14838
|
/**
|
|
14205
14839
|
* Starts publishing the given video stream to the call.
|
|
@@ -14270,7 +14904,7 @@ class Call {
|
|
|
14270
14904
|
this.stopPublish = async (...trackTypes) => {
|
|
14271
14905
|
if (!this.sfuClient || !this.publisher)
|
|
14272
14906
|
return;
|
|
14273
|
-
this.publisher.stopTracks(...trackTypes);
|
|
14907
|
+
await this.publisher.stopTracks(...trackTypes);
|
|
14274
14908
|
await this.updateLocalStreamState(undefined, ...trackTypes);
|
|
14275
14909
|
};
|
|
14276
14910
|
/**
|
|
@@ -14299,6 +14933,20 @@ class Call {
|
|
|
14299
14933
|
}));
|
|
14300
14934
|
}
|
|
14301
14935
|
};
|
|
14936
|
+
/**
|
|
14937
|
+
* Re-arms the encoder for a currently published track type. Useful for
|
|
14938
|
+
* working around WebKit's stalled sender bug after an iOS audio session
|
|
14939
|
+
* interruption (Siri, PSTN call).
|
|
14940
|
+
*
|
|
14941
|
+
* @internal
|
|
14942
|
+
*
|
|
14943
|
+
* @param trackType the track type to refresh.
|
|
14944
|
+
*/
|
|
14945
|
+
this.refreshPublishedTrack = async (trackType) => {
|
|
14946
|
+
if (!this.publisher)
|
|
14947
|
+
return;
|
|
14948
|
+
await this.publisher.refreshTrack(trackType);
|
|
14949
|
+
};
|
|
14302
14950
|
/**
|
|
14303
14951
|
* Updates the preferred publishing options
|
|
14304
14952
|
*
|
|
@@ -14960,7 +15608,7 @@ class Call {
|
|
|
14960
15608
|
* @param trackType the video mode.
|
|
14961
15609
|
*/
|
|
14962
15610
|
this.trackElementVisibility = (element, sessionId, trackType) => {
|
|
14963
|
-
return this.
|
|
15611
|
+
return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
|
|
14964
15612
|
};
|
|
14965
15613
|
/**
|
|
14966
15614
|
* Sets the viewport element to track bound video elements for visibility.
|
|
@@ -14968,7 +15616,7 @@ class Call {
|
|
|
14968
15616
|
* @param element the viewport element.
|
|
14969
15617
|
*/
|
|
14970
15618
|
this.setViewport = (element) => {
|
|
14971
|
-
return this.
|
|
15619
|
+
return this.viewportTracker?.setViewport(element);
|
|
14972
15620
|
};
|
|
14973
15621
|
/**
|
|
14974
15622
|
* Binds a DOM <video> element to the given session id.
|
|
@@ -14986,7 +15634,7 @@ class Call {
|
|
|
14986
15634
|
* @param trackType the kind of video.
|
|
14987
15635
|
*/
|
|
14988
15636
|
this.bindVideoElement = (videoElement, sessionId, trackType) => {
|
|
14989
|
-
const unbind = this.dynascaleManager
|
|
15637
|
+
const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
|
|
14990
15638
|
if (!unbind)
|
|
14991
15639
|
return;
|
|
14992
15640
|
this.leaveCallHooks.add(unbind);
|
|
@@ -15006,21 +15654,28 @@ class Call {
|
|
|
15006
15654
|
* @param trackType the kind of audio.
|
|
15007
15655
|
*/
|
|
15008
15656
|
this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
|
|
15009
|
-
const unbind = this.dynascaleManager
|
|
15657
|
+
const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
|
|
15010
15658
|
if (!unbind)
|
|
15011
15659
|
return;
|
|
15012
|
-
this.
|
|
15013
|
-
|
|
15014
|
-
this.leaveCallHooks.delete(unbind);
|
|
15660
|
+
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
15661
|
+
const cleanup = () => {
|
|
15015
15662
|
unbind();
|
|
15663
|
+
this.audioBindingsWatchdog?.unregister(sessionId, trackType);
|
|
15664
|
+
};
|
|
15665
|
+
this.leaveCallHooks.add(cleanup);
|
|
15666
|
+
return () => {
|
|
15667
|
+
this.leaveCallHooks.delete(cleanup);
|
|
15668
|
+
cleanup();
|
|
15016
15669
|
};
|
|
15017
15670
|
};
|
|
15018
15671
|
/**
|
|
15019
15672
|
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
15673
|
+
* Must be called from within a user gesture (e.g., click handler).
|
|
15674
|
+
*
|
|
15675
|
+
* Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
|
|
15676
|
+
* gesture is required.
|
|
15020
15677
|
*/
|
|
15021
|
-
this.resumeAudio = () =>
|
|
15022
|
-
return this.dynascaleManager.resumeAudio();
|
|
15023
|
-
};
|
|
15678
|
+
this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
|
|
15024
15679
|
/**
|
|
15025
15680
|
* Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
|
|
15026
15681
|
*
|
|
@@ -15058,21 +15713,21 @@ class Call {
|
|
|
15058
15713
|
* preference has effect on. Affects all participants by default.
|
|
15059
15714
|
*/
|
|
15060
15715
|
this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
|
|
15061
|
-
this.
|
|
15716
|
+
this.trackSubscriptionManager.setOverrides(resolution
|
|
15062
15717
|
? {
|
|
15063
15718
|
enabled: true,
|
|
15064
15719
|
dimension: resolution,
|
|
15065
15720
|
}
|
|
15066
15721
|
: undefined, sessionIds);
|
|
15067
|
-
this.
|
|
15722
|
+
this.trackSubscriptionManager.apply();
|
|
15068
15723
|
};
|
|
15069
15724
|
/**
|
|
15070
15725
|
* Enables or disables incoming video from all remote call participants,
|
|
15071
15726
|
* and removes any preference for preferred resolution.
|
|
15072
15727
|
*/
|
|
15073
15728
|
this.setIncomingVideoEnabled = (enabled) => {
|
|
15074
|
-
this.
|
|
15075
|
-
this.
|
|
15729
|
+
this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
|
|
15730
|
+
this.trackSubscriptionManager.apply();
|
|
15076
15731
|
};
|
|
15077
15732
|
/**
|
|
15078
15733
|
* Sets the maximum amount of time a user can remain waiting for a reconnect
|
|
@@ -15153,7 +15808,13 @@ class Call {
|
|
|
15153
15808
|
this.microphone = new MicrophoneManager(this, preferences);
|
|
15154
15809
|
this.speaker = new SpeakerManager(this, preferences);
|
|
15155
15810
|
this.screenShare = new ScreenShareManager(this);
|
|
15156
|
-
this.
|
|
15811
|
+
this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
|
|
15812
|
+
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
15813
|
+
if (typeof document !== 'undefined') {
|
|
15814
|
+
this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
|
|
15815
|
+
this.viewportTracker = new ViewportTracker(this.state);
|
|
15816
|
+
this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
|
|
15817
|
+
}
|
|
15157
15818
|
}
|
|
15158
15819
|
/**
|
|
15159
15820
|
* A flag indicating whether the call is "ringing" type of call.
|
|
@@ -15226,12 +15887,118 @@ const APIErrorCodes = {
|
|
|
15226
15887
|
*/
|
|
15227
15888
|
class StableWSConnection {
|
|
15228
15889
|
constructor(client) {
|
|
15890
|
+
/** Incremented when a new WS connection is made */
|
|
15891
|
+
this.wsID = 1;
|
|
15892
|
+
// Connection lifecycle flags.
|
|
15893
|
+
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15894
|
+
this.isConnecting = false;
|
|
15895
|
+
/** To avoid reconnect if client is disconnected */
|
|
15896
|
+
this.isDisconnected = false;
|
|
15897
|
+
/** Boolean that indicates if we have a working connection to the server */
|
|
15898
|
+
this.isHealthy = false;
|
|
15899
|
+
/** Boolean that indicates if the connection promise is resolved */
|
|
15900
|
+
this.isConnectionOpenResolved = false;
|
|
15901
|
+
// Failure counters (drive retry/backoff scheduling).
|
|
15902
|
+
/** consecutive failures influence the duration of the timeout */
|
|
15903
|
+
this.consecutiveFailures = 0;
|
|
15904
|
+
/** keep track of the total number of failures */
|
|
15905
|
+
this.totalFailures = 0;
|
|
15906
|
+
// Health-check pings + connection-staleness check.
|
|
15907
|
+
/** Send a health check message every 25 seconds */
|
|
15908
|
+
this.pingInterval = 25 * 1000;
|
|
15909
|
+
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15910
|
+
/** Store the last event time for health checks */
|
|
15911
|
+
this.lastEvent = null;
|
|
15229
15912
|
this._log = (msg, extra = {}, level = 'info') => {
|
|
15230
15913
|
this.client.logger[level](`connection:${msg}`, extra);
|
|
15231
15914
|
};
|
|
15232
15915
|
this.setClient = (client) => {
|
|
15233
15916
|
this.client = client;
|
|
15234
15917
|
};
|
|
15918
|
+
/**
|
|
15919
|
+
* connect - Connect to the WS URL
|
|
15920
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15921
|
+
* @return Promise that completes once the first health check message is received
|
|
15922
|
+
*/
|
|
15923
|
+
this.connect = async (timeout = 15000) => {
|
|
15924
|
+
if (this.isConnecting) {
|
|
15925
|
+
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15926
|
+
}
|
|
15927
|
+
this.isDisconnected = false;
|
|
15928
|
+
try {
|
|
15929
|
+
const healthCheck = await this._connect(timeout);
|
|
15930
|
+
this.consecutiveFailures = 0;
|
|
15931
|
+
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15932
|
+
}
|
|
15933
|
+
catch (caught) {
|
|
15934
|
+
const error = caught;
|
|
15935
|
+
this.isHealthy = false;
|
|
15936
|
+
this.consecutiveFailures += 1;
|
|
15937
|
+
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15938
|
+
!this.client.tokenManager.isStatic()) {
|
|
15939
|
+
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15940
|
+
this._reconnect({ refreshToken: true });
|
|
15941
|
+
}
|
|
15942
|
+
else if (!error.isWSFailure) {
|
|
15943
|
+
// API rejected the connection and we should not retry
|
|
15944
|
+
throw new Error(JSON.stringify({
|
|
15945
|
+
code: error.code,
|
|
15946
|
+
StatusCode: error.StatusCode,
|
|
15947
|
+
message: error.message,
|
|
15948
|
+
isWSFailure: error.isWSFailure,
|
|
15949
|
+
}));
|
|
15950
|
+
}
|
|
15951
|
+
else {
|
|
15952
|
+
// Transient WS failure (e.g., handshake watchdog). Kick off a
|
|
15953
|
+
// reconnect chain so _waitForHealthy(timeout) below has something
|
|
15954
|
+
// to poll for. Owning the trigger here (rather than inside
|
|
15955
|
+
// _connect()'s catch) keeps a single failure from spawning two
|
|
15956
|
+
// parallel chains - one from this catch and one from _reconnect's
|
|
15957
|
+
// own catch when _connect was called from there.
|
|
15958
|
+
this._reconnect();
|
|
15959
|
+
}
|
|
15960
|
+
}
|
|
15961
|
+
return await this._waitForHealthy(timeout);
|
|
15962
|
+
};
|
|
15963
|
+
/**
|
|
15964
|
+
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15965
|
+
* the default 15s timeout allows between 2~3 tries
|
|
15966
|
+
* @param timeout duration(ms)
|
|
15967
|
+
*/
|
|
15968
|
+
this._waitForHealthy = async (timeout = 15000) => {
|
|
15969
|
+
return Promise.race([
|
|
15970
|
+
(async () => {
|
|
15971
|
+
const interval = 50; // ms
|
|
15972
|
+
for (let i = 0; i <= timeout; i += interval) {
|
|
15973
|
+
try {
|
|
15974
|
+
return await this.connectionOpen;
|
|
15975
|
+
}
|
|
15976
|
+
catch (caught) {
|
|
15977
|
+
const error = caught;
|
|
15978
|
+
if (i === timeout) {
|
|
15979
|
+
throw new Error(JSON.stringify({
|
|
15980
|
+
code: error.code,
|
|
15981
|
+
StatusCode: error.StatusCode,
|
|
15982
|
+
message: error.message,
|
|
15983
|
+
isWSFailure: error.isWSFailure,
|
|
15984
|
+
}));
|
|
15985
|
+
}
|
|
15986
|
+
await sleep(interval);
|
|
15987
|
+
}
|
|
15988
|
+
}
|
|
15989
|
+
})(),
|
|
15990
|
+
(async () => {
|
|
15991
|
+
await sleep(timeout);
|
|
15992
|
+
this.isConnecting = false;
|
|
15993
|
+
throw new Error(JSON.stringify({
|
|
15994
|
+
code: '',
|
|
15995
|
+
StatusCode: '',
|
|
15996
|
+
message: 'initial WS connection could not be established',
|
|
15997
|
+
isWSFailure: true,
|
|
15998
|
+
}));
|
|
15999
|
+
})(),
|
|
16000
|
+
]);
|
|
16001
|
+
};
|
|
15235
16002
|
/**
|
|
15236
16003
|
* Builds and returns the url for websocket.
|
|
15237
16004
|
* @private
|
|
@@ -15244,11 +16011,166 @@ class StableWSConnection {
|
|
|
15244
16011
|
params.set('X-Stream-Client', this.client.getUserAgent());
|
|
15245
16012
|
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
|
|
15246
16013
|
};
|
|
16014
|
+
/**
|
|
16015
|
+
* disconnect - Disconnect the connection and doesn't recover...
|
|
16016
|
+
*/
|
|
16017
|
+
this.disconnect = (timeout) => {
|
|
16018
|
+
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
16019
|
+
this.wsID += 1;
|
|
16020
|
+
this.isConnecting = false;
|
|
16021
|
+
this.isDisconnected = true;
|
|
16022
|
+
// start by removing all the listeners
|
|
16023
|
+
if (this.healthCheckTimeoutRef) {
|
|
16024
|
+
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
16025
|
+
}
|
|
16026
|
+
if (this.connectionCheckTimeoutRef) {
|
|
16027
|
+
clearInterval(this.connectionCheckTimeoutRef);
|
|
16028
|
+
}
|
|
16029
|
+
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
16030
|
+
this.isHealthy = false;
|
|
16031
|
+
let isClosedPromise;
|
|
16032
|
+
// and finally close...
|
|
16033
|
+
// Assigning to local here because we will remove it from this before the
|
|
16034
|
+
// promise resolves.
|
|
16035
|
+
const { ws } = this;
|
|
16036
|
+
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
16037
|
+
isClosedPromise = new Promise((resolve) => {
|
|
16038
|
+
const onclose = (event) => {
|
|
16039
|
+
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
16040
|
+
resolve();
|
|
16041
|
+
};
|
|
16042
|
+
ws.onclose = onclose;
|
|
16043
|
+
// In case we don't receive close frame websocket server in time,
|
|
16044
|
+
// lets not wait for more than 1 second.
|
|
16045
|
+
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
16046
|
+
});
|
|
16047
|
+
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
16048
|
+
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
16049
|
+
}
|
|
16050
|
+
else {
|
|
16051
|
+
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
16052
|
+
isClosedPromise = Promise.resolve();
|
|
16053
|
+
}
|
|
16054
|
+
delete this.ws;
|
|
16055
|
+
return isClosedPromise;
|
|
16056
|
+
};
|
|
16057
|
+
/**
|
|
16058
|
+
* _connect - Connect to the WS endpoint
|
|
16059
|
+
*
|
|
16060
|
+
* @param timeoutMs handshake watchdog deadline in ms. Defaults to
|
|
16061
|
+
* `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
|
|
16062
|
+
* passes its own timeout through so caller-supplied deadlines are honored.
|
|
16063
|
+
* @return Promise that completes once the first health check message is received
|
|
16064
|
+
*/
|
|
16065
|
+
this._connect = async (timeoutMs) => {
|
|
16066
|
+
if (this.isConnecting)
|
|
16067
|
+
return; // ignore _connect if it's currently trying to connect
|
|
16068
|
+
this.isConnecting = true;
|
|
16069
|
+
// Snapshot of the connection-id reject closure owned by THIS attempt.
|
|
16070
|
+
// Captured at function entry so that even early failures (e.g.,
|
|
16071
|
+
// tokenManager.loadToken throwing before we reach the WS phase) can
|
|
16072
|
+
// settle the promise the caller is awaiting. Re-captured below if
|
|
16073
|
+
// _connect itself sets up a fresh promise. If a concurrent
|
|
16074
|
+
// openConnection() rotates `client.rejectConnectionId` later, our
|
|
16075
|
+
// captured closure still settles only the original promise (P1) and
|
|
16076
|
+
// never poisons the newer one (P2).
|
|
16077
|
+
let ownRejectConnectionId = this.client.rejectConnectionId;
|
|
16078
|
+
let isTokenReady = false;
|
|
16079
|
+
try {
|
|
16080
|
+
this._log(`_connect() - waiting for token`);
|
|
16081
|
+
await this.client.tokenManager.tokenReady();
|
|
16082
|
+
isTokenReady = true;
|
|
16083
|
+
}
|
|
16084
|
+
catch {
|
|
16085
|
+
// token provider has failed before, so try again
|
|
16086
|
+
}
|
|
16087
|
+
try {
|
|
16088
|
+
if (!isTokenReady) {
|
|
16089
|
+
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
16090
|
+
await this.client.tokenManager.loadToken();
|
|
16091
|
+
}
|
|
16092
|
+
if (!this.client.isConnectionIdPromisePending) {
|
|
16093
|
+
this.client._setupConnectionIdPromise();
|
|
16094
|
+
// recapture: we just rotated the resolver ourselves, the new
|
|
16095
|
+
// closure is the one bound to the promise this attempt owns.
|
|
16096
|
+
ownRejectConnectionId = this.client.rejectConnectionId;
|
|
16097
|
+
}
|
|
16098
|
+
this._setupConnectionPromise();
|
|
16099
|
+
const wsURL = this._buildUrl();
|
|
16100
|
+
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
16101
|
+
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
16102
|
+
this.ws = new WS(wsURL);
|
|
16103
|
+
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
16104
|
+
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
16105
|
+
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
16106
|
+
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
16107
|
+
// race the WS handshake against an explicit deadline so a silent
|
|
16108
|
+
// network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
|
|
16109
|
+
const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
|
|
16110
|
+
const timers = getTimers();
|
|
16111
|
+
let handshakeTimeoutId;
|
|
16112
|
+
let response;
|
|
16113
|
+
try {
|
|
16114
|
+
response = await Promise.race([
|
|
16115
|
+
this.connectionOpen,
|
|
16116
|
+
new Promise((_, reject) => {
|
|
16117
|
+
handshakeTimeoutId = timers.setTimeout(() => {
|
|
16118
|
+
const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
|
|
16119
|
+
err.isWSFailure = true;
|
|
16120
|
+
reject(err);
|
|
16121
|
+
}, handshakeTimeout);
|
|
16122
|
+
}),
|
|
16123
|
+
]);
|
|
16124
|
+
}
|
|
16125
|
+
finally {
|
|
16126
|
+
timers.clearTimeout(handshakeTimeoutId);
|
|
16127
|
+
}
|
|
16128
|
+
this.isConnecting = false;
|
|
16129
|
+
// If we were disconnected during the handshake (e.g. closeConnection()
|
|
16130
|
+
// ran while a background _reconnect's _connect was in flight), tear
|
|
16131
|
+
// down the new WS and throw so the caller of connect() does not get
|
|
16132
|
+
// a misleading "success" for a connection that has already been
|
|
16133
|
+
// aborted. We must NOT skip the throw and just return undefined: the
|
|
16134
|
+
// outer connect() would otherwise fall through to _waitForHealthy(),
|
|
16135
|
+
// which would observe the already-resolved connectionOpen promise
|
|
16136
|
+
// and resolve with a ConnectedEvent for a torn-down connection.
|
|
16137
|
+
if (this.isDisconnected) {
|
|
16138
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
16139
|
+
this._destroyCurrentWSConnection();
|
|
16140
|
+
}
|
|
16141
|
+
throw new Error('WS handshake aborted: disconnect() ran while connecting');
|
|
16142
|
+
}
|
|
16143
|
+
if (response) {
|
|
16144
|
+
this.connectionID = response.connection_id;
|
|
16145
|
+
this.client.resolveConnectionId?.(this.connectionID);
|
|
16146
|
+
return response;
|
|
16147
|
+
}
|
|
16148
|
+
}
|
|
16149
|
+
catch (caught) {
|
|
16150
|
+
const err = caught;
|
|
16151
|
+
this.isConnecting = false;
|
|
16152
|
+
this._log(`_connect() - Error - `, err);
|
|
16153
|
+
// Reject THIS attempt's connection-id promise (P1) directly via the
|
|
16154
|
+
// captured closure. Whether or not a concurrent openConnection() has
|
|
16155
|
+
// since rotated client.rejectConnectionId to a newer promise (P2),
|
|
16156
|
+
// calling ownRejectConnectionId only settles P1 - P2 is untouched.
|
|
16157
|
+
// P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
|
|
16158
|
+
// therefore fail fast instead of being orphaned.
|
|
16159
|
+
ownRejectConnectionId?.(err);
|
|
16160
|
+
// connectionOpen is per-instance and not subject to rotation, so
|
|
16161
|
+
// calling it unconditionally is safe (and a no-op if already settled).
|
|
16162
|
+
this.rejectConnectionOpen?.(err);
|
|
16163
|
+
// tear down a half-open WS so it does not linger and fire a stale wsID later
|
|
16164
|
+
if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
|
|
16165
|
+
this._destroyCurrentWSConnection();
|
|
16166
|
+
}
|
|
16167
|
+
throw err;
|
|
16168
|
+
}
|
|
16169
|
+
};
|
|
15247
16170
|
/**
|
|
15248
16171
|
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
|
|
15249
16172
|
*
|
|
15250
16173
|
* @param {Event} event Event with type online or offline
|
|
15251
|
-
*
|
|
15252
16174
|
*/
|
|
15253
16175
|
this.onlineStatusChanged = (event) => {
|
|
15254
16176
|
if (event.type === 'offline') {
|
|
@@ -15346,16 +16268,12 @@ class StableWSConnection {
|
|
|
15346
16268
|
return;
|
|
15347
16269
|
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
|
|
15348
16270
|
if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
|
|
15349
|
-
// this is a permanent error raised by stream
|
|
16271
|
+
// this is a permanent error raised by stream.
|
|
15350
16272
|
// usually caused by invalid auth details
|
|
15351
16273
|
const error = new Error(`WS connection reject with error ${event.reason}`);
|
|
15352
|
-
// @ts-expect-error type issue
|
|
15353
16274
|
error.reason = event.reason;
|
|
15354
|
-
// @ts-expect-error type issue
|
|
15355
16275
|
error.code = event.code;
|
|
15356
|
-
// @ts-expect-error type issue
|
|
15357
16276
|
error.wasClean = event.wasClean;
|
|
15358
|
-
// @ts-expect-error type issue
|
|
15359
16277
|
error.target = event.target;
|
|
15360
16278
|
this.rejectConnectionOpen?.(error);
|
|
15361
16279
|
this._log(`onclose() - WS connection reject with error ${event.reason}`, {
|
|
@@ -15493,205 +16411,8 @@ class StableWSConnection {
|
|
|
15493
16411
|
}, this.connectionCheckTimeout);
|
|
15494
16412
|
};
|
|
15495
16413
|
this.client = client;
|
|
15496
|
-
/** consecutive failures influence the duration of the timeout */
|
|
15497
|
-
this.consecutiveFailures = 0;
|
|
15498
|
-
/** keep track of the total number of failures */
|
|
15499
|
-
this.totalFailures = 0;
|
|
15500
|
-
/** We only make 1 attempt to reconnect at the same time.. */
|
|
15501
|
-
this.isConnecting = false;
|
|
15502
|
-
/** To avoid reconnect if client is disconnected */
|
|
15503
|
-
this.isDisconnected = false;
|
|
15504
|
-
/** Boolean that indicates if the connection promise is resolved */
|
|
15505
|
-
this.isConnectionOpenResolved = false;
|
|
15506
|
-
/** Boolean that indicates if we have a working connection to the server */
|
|
15507
|
-
this.isHealthy = false;
|
|
15508
|
-
/** Incremented when a new WS connection is made */
|
|
15509
|
-
this.wsID = 1;
|
|
15510
|
-
/** Store the last event time for health checks */
|
|
15511
|
-
this.lastEvent = null;
|
|
15512
|
-
/** Send a health check message every 25 seconds */
|
|
15513
|
-
this.pingInterval = 25 * 1000;
|
|
15514
|
-
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
|
|
15515
16414
|
addConnectionEventListeners(this.onlineStatusChanged);
|
|
15516
16415
|
}
|
|
15517
|
-
/**
|
|
15518
|
-
* connect - Connect to the WS URL
|
|
15519
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15520
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15521
|
-
*/
|
|
15522
|
-
async connect(timeout = 15000) {
|
|
15523
|
-
if (this.isConnecting) {
|
|
15524
|
-
throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
|
|
15525
|
-
}
|
|
15526
|
-
this.isDisconnected = false;
|
|
15527
|
-
try {
|
|
15528
|
-
const healthCheck = await this._connect();
|
|
15529
|
-
this.consecutiveFailures = 0;
|
|
15530
|
-
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
|
|
15531
|
-
}
|
|
15532
|
-
catch (error) {
|
|
15533
|
-
this.isHealthy = false;
|
|
15534
|
-
this.consecutiveFailures += 1;
|
|
15535
|
-
if (
|
|
15536
|
-
// @ts-expect-error type issue
|
|
15537
|
-
error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
15538
|
-
!this.client.tokenManager.isStatic()) {
|
|
15539
|
-
this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
|
|
15540
|
-
this._reconnect({ refreshToken: true });
|
|
15541
|
-
}
|
|
15542
|
-
else {
|
|
15543
|
-
// @ts-expect-error type issue
|
|
15544
|
-
if (!error.isWSFailure) {
|
|
15545
|
-
// API rejected the connection and we should not retry
|
|
15546
|
-
throw new Error(JSON.stringify({
|
|
15547
|
-
// @ts-expect-error type issue
|
|
15548
|
-
code: error.code,
|
|
15549
|
-
// @ts-expect-error type issue
|
|
15550
|
-
StatusCode: error.StatusCode,
|
|
15551
|
-
// @ts-expect-error type issue
|
|
15552
|
-
message: error.message,
|
|
15553
|
-
// @ts-expect-error type issue
|
|
15554
|
-
isWSFailure: error.isWSFailure,
|
|
15555
|
-
}));
|
|
15556
|
-
}
|
|
15557
|
-
}
|
|
15558
|
-
}
|
|
15559
|
-
return await this._waitForHealthy(timeout);
|
|
15560
|
-
}
|
|
15561
|
-
/**
|
|
15562
|
-
* _waitForHealthy polls the promise connection to see if its resolved until it times out
|
|
15563
|
-
* the default 15s timeout allows between 2~3 tries
|
|
15564
|
-
* @param timeout duration(ms)
|
|
15565
|
-
*/
|
|
15566
|
-
async _waitForHealthy(timeout = 15000) {
|
|
15567
|
-
return Promise.race([
|
|
15568
|
-
(async () => {
|
|
15569
|
-
const interval = 50; // ms
|
|
15570
|
-
for (let i = 0; i <= timeout; i += interval) {
|
|
15571
|
-
try {
|
|
15572
|
-
return await this.connectionOpen;
|
|
15573
|
-
}
|
|
15574
|
-
catch (error) {
|
|
15575
|
-
if (i === timeout) {
|
|
15576
|
-
throw new Error(JSON.stringify({
|
|
15577
|
-
code: error.code,
|
|
15578
|
-
StatusCode: error.StatusCode,
|
|
15579
|
-
message: error.message,
|
|
15580
|
-
isWSFailure: error.isWSFailure,
|
|
15581
|
-
}));
|
|
15582
|
-
}
|
|
15583
|
-
await sleep(interval);
|
|
15584
|
-
}
|
|
15585
|
-
}
|
|
15586
|
-
})(),
|
|
15587
|
-
(async () => {
|
|
15588
|
-
await sleep(timeout);
|
|
15589
|
-
this.isConnecting = false;
|
|
15590
|
-
throw new Error(JSON.stringify({
|
|
15591
|
-
code: '',
|
|
15592
|
-
StatusCode: '',
|
|
15593
|
-
message: 'initial WS connection could not be established',
|
|
15594
|
-
isWSFailure: true,
|
|
15595
|
-
}));
|
|
15596
|
-
})(),
|
|
15597
|
-
]);
|
|
15598
|
-
}
|
|
15599
|
-
/**
|
|
15600
|
-
* disconnect - Disconnect the connection and doesn't recover...
|
|
15601
|
-
*
|
|
15602
|
-
*/
|
|
15603
|
-
disconnect(timeout) {
|
|
15604
|
-
this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
|
|
15605
|
-
this.wsID += 1;
|
|
15606
|
-
this.isConnecting = false;
|
|
15607
|
-
this.isDisconnected = true;
|
|
15608
|
-
// start by removing all the listeners
|
|
15609
|
-
if (this.healthCheckTimeoutRef) {
|
|
15610
|
-
getTimers().clearInterval(this.healthCheckTimeoutRef);
|
|
15611
|
-
}
|
|
15612
|
-
if (this.connectionCheckTimeoutRef) {
|
|
15613
|
-
clearInterval(this.connectionCheckTimeoutRef);
|
|
15614
|
-
}
|
|
15615
|
-
removeConnectionEventListeners(this.onlineStatusChanged);
|
|
15616
|
-
this.isHealthy = false;
|
|
15617
|
-
let isClosedPromise;
|
|
15618
|
-
// and finally close...
|
|
15619
|
-
// Assigning to local here because we will remove it from this before the
|
|
15620
|
-
// promise resolves.
|
|
15621
|
-
const { ws } = this;
|
|
15622
|
-
if (ws && ws.close && ws.readyState === ws.OPEN) {
|
|
15623
|
-
isClosedPromise = new Promise((resolve) => {
|
|
15624
|
-
const onclose = (event) => {
|
|
15625
|
-
this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
|
|
15626
|
-
resolve();
|
|
15627
|
-
};
|
|
15628
|
-
ws.onclose = onclose;
|
|
15629
|
-
// In case we don't receive close frame websocket server in time,
|
|
15630
|
-
// lets not wait for more than 1 second.
|
|
15631
|
-
setTimeout(onclose, timeout != null ? timeout : 1000);
|
|
15632
|
-
});
|
|
15633
|
-
this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
|
|
15634
|
-
ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
|
|
15635
|
-
}
|
|
15636
|
-
else {
|
|
15637
|
-
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
|
|
15638
|
-
isClosedPromise = Promise.resolve();
|
|
15639
|
-
}
|
|
15640
|
-
delete this.ws;
|
|
15641
|
-
return isClosedPromise;
|
|
15642
|
-
}
|
|
15643
|
-
/**
|
|
15644
|
-
* _connect - Connect to the WS endpoint
|
|
15645
|
-
*
|
|
15646
|
-
* @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
|
|
15647
|
-
*/
|
|
15648
|
-
async _connect() {
|
|
15649
|
-
if (this.isConnecting)
|
|
15650
|
-
return; // ignore _connect if it's currently trying to connect
|
|
15651
|
-
this.isConnecting = true;
|
|
15652
|
-
let isTokenReady = false;
|
|
15653
|
-
try {
|
|
15654
|
-
this._log(`_connect() - waiting for token`);
|
|
15655
|
-
await this.client.tokenManager.tokenReady();
|
|
15656
|
-
isTokenReady = true;
|
|
15657
|
-
}
|
|
15658
|
-
catch {
|
|
15659
|
-
// token provider has failed before, so try again
|
|
15660
|
-
}
|
|
15661
|
-
try {
|
|
15662
|
-
if (!isTokenReady) {
|
|
15663
|
-
this._log(`_connect() - tokenProvider failed before, so going to retry`);
|
|
15664
|
-
await this.client.tokenManager.loadToken();
|
|
15665
|
-
}
|
|
15666
|
-
if (!this.client.isConnectionIsPromisePending) {
|
|
15667
|
-
this.client._setupConnectionIdPromise();
|
|
15668
|
-
}
|
|
15669
|
-
this._setupConnectionPromise();
|
|
15670
|
-
const wsURL = this._buildUrl();
|
|
15671
|
-
this._log(`_connect() - Connecting to ${wsURL}`);
|
|
15672
|
-
const WS = this.client.options.WebSocketImpl ?? WebSocket;
|
|
15673
|
-
this.ws = new WS(wsURL);
|
|
15674
|
-
this.ws.onopen = this.onopen.bind(this, this.wsID);
|
|
15675
|
-
this.ws.onclose = this.onclose.bind(this, this.wsID);
|
|
15676
|
-
this.ws.onerror = this.onerror.bind(this, this.wsID);
|
|
15677
|
-
this.ws.onmessage = this.onmessage.bind(this, this.wsID);
|
|
15678
|
-
const response = await this.connectionOpen;
|
|
15679
|
-
this.isConnecting = false;
|
|
15680
|
-
if (response) {
|
|
15681
|
-
this.connectionID = response.connection_id;
|
|
15682
|
-
this.client.resolveConnectionId?.(this.connectionID);
|
|
15683
|
-
return response;
|
|
15684
|
-
}
|
|
15685
|
-
}
|
|
15686
|
-
catch (err) {
|
|
15687
|
-
this.client._setupConnectionIdPromise();
|
|
15688
|
-
this.isConnecting = false;
|
|
15689
|
-
// @ts-expect-error type issue
|
|
15690
|
-
this._log(`_connect() - Error - `, err);
|
|
15691
|
-
this.client.rejectConnectionId?.(err);
|
|
15692
|
-
throw err;
|
|
15693
|
-
}
|
|
15694
|
-
}
|
|
15695
16416
|
/**
|
|
15696
16417
|
* _reconnect - Retry the connection to WS endpoint
|
|
15697
16418
|
*
|
|
@@ -15738,7 +16459,8 @@ class StableWSConnection {
|
|
|
15738
16459
|
this._log('_reconnect() - Finished recoverCallBack');
|
|
15739
16460
|
this.consecutiveFailures = 0;
|
|
15740
16461
|
}
|
|
15741
|
-
catch (
|
|
16462
|
+
catch (caught) {
|
|
16463
|
+
const error = caught;
|
|
15742
16464
|
this.isHealthy = false;
|
|
15743
16465
|
this.consecutiveFailures += 1;
|
|
15744
16466
|
if (error.code === KnownCodes.TOKEN_EXPIRED &&
|
|
@@ -16295,7 +17017,7 @@ class StreamClient {
|
|
|
16295
17017
|
this.getUserAgent = () => {
|
|
16296
17018
|
if (!this.cachedUserAgent) {
|
|
16297
17019
|
const { clientAppIdentifier = {} } = this.options;
|
|
16298
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
17020
|
+
const { sdkName = 'js', sdkVersion = "1.51.0", ...extras } = clientAppIdentifier;
|
|
16299
17021
|
this.cachedUserAgent = [
|
|
16300
17022
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16301
17023
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -16403,7 +17125,7 @@ class StreamClient {
|
|
|
16403
17125
|
get connectionIdPromise() {
|
|
16404
17126
|
return this.connectionIdPromiseSafe?.();
|
|
16405
17127
|
}
|
|
16406
|
-
get
|
|
17128
|
+
get isConnectionIdPromisePending() {
|
|
16407
17129
|
return this.connectionIdPromiseSafe?.checkPending() ?? false;
|
|
16408
17130
|
}
|
|
16409
17131
|
get wsPromise() {
|