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