@stream-io/video-client 1.61.1 → 1.62.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 +21 -0
- package/dist/index.browser.es.js +525 -178
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +525 -178
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +525 -178
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/devices/DeviceManagerState.d.ts +17 -0
- package/dist/src/devices/MicrophoneManager.d.ts +11 -0
- package/dist/src/devices/devices.d.ts +5 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +110 -9
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +26 -1
- package/src/devices/DeviceManagerState.ts +24 -0
- package/src/devices/MicrophoneManager.ts +79 -17
- package/src/devices/SpeakerManager.ts +1 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/DeviceManager.test.ts +29 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
- package/src/devices/__tests__/devices.test.ts +10 -14
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +86 -11
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +1 -1
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- package/src/stats/rtc/types.ts +0 -13
package/dist/index.es.js
CHANGED
|
@@ -2666,18 +2666,6 @@ class SendStatsRequest$Type extends MessageType {
|
|
|
2666
2666
|
constructor() {
|
|
2667
2667
|
super('stream.video.sfu.signal.SendStatsRequest', [
|
|
2668
2668
|
{ no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
2669
|
-
{
|
|
2670
|
-
no: 2,
|
|
2671
|
-
name: 'subscriber_stats',
|
|
2672
|
-
kind: 'scalar',
|
|
2673
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2674
|
-
},
|
|
2675
|
-
{
|
|
2676
|
-
no: 3,
|
|
2677
|
-
name: 'publisher_stats',
|
|
2678
|
-
kind: 'scalar',
|
|
2679
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2680
|
-
},
|
|
2681
2669
|
{
|
|
2682
2670
|
no: 4,
|
|
2683
2671
|
name: 'webrtc_version',
|
|
@@ -2691,8 +2679,6 @@ class SendStatsRequest$Type extends MessageType {
|
|
|
2691
2679
|
kind: 'scalar',
|
|
2692
2680
|
T: 9 /*ScalarType.STRING*/,
|
|
2693
2681
|
},
|
|
2694
|
-
{ no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
|
|
2695
|
-
{ no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
|
|
2696
2682
|
{
|
|
2697
2683
|
no: 9,
|
|
2698
2684
|
name: 'android',
|
|
@@ -2709,18 +2695,6 @@ class SendStatsRequest$Type extends MessageType {
|
|
|
2709
2695
|
},
|
|
2710
2696
|
{ no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
|
|
2711
2697
|
{ no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
|
|
2712
|
-
{
|
|
2713
|
-
no: 13,
|
|
2714
|
-
name: 'subscriber_rtc_stats',
|
|
2715
|
-
kind: 'scalar',
|
|
2716
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2717
|
-
},
|
|
2718
|
-
{
|
|
2719
|
-
no: 14,
|
|
2720
|
-
name: 'publisher_rtc_stats',
|
|
2721
|
-
kind: 'scalar',
|
|
2722
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2723
|
-
},
|
|
2724
2698
|
{ no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
2725
2699
|
{
|
|
2726
2700
|
no: 16,
|
|
@@ -5082,7 +5056,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5082
5056
|
}
|
|
5083
5057
|
};
|
|
5084
5058
|
|
|
5085
|
-
const version = "1.
|
|
5059
|
+
const version = "1.62.0";
|
|
5086
5060
|
const [major, minor, patch] = version.split('.');
|
|
5087
5061
|
let sdkInfo = {
|
|
5088
5062
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7810,30 +7784,21 @@ class SfuStatsReporter {
|
|
|
7810
7784
|
sfuClient;
|
|
7811
7785
|
subscriber;
|
|
7812
7786
|
publisher;
|
|
7813
|
-
microphone;
|
|
7814
|
-
camera;
|
|
7815
|
-
state;
|
|
7816
7787
|
tracer;
|
|
7817
7788
|
unifiedSessionId;
|
|
7818
7789
|
intervalId;
|
|
7819
7790
|
timeoutId;
|
|
7820
7791
|
reportCount = 0;
|
|
7821
|
-
unsubscribeDevicePermissionsSubscription;
|
|
7822
|
-
unsubscribeListDevicesSubscription;
|
|
7823
7792
|
sdkName;
|
|
7824
7793
|
sdkVersion;
|
|
7825
7794
|
webRTCVersion;
|
|
7826
|
-
inputDevices = new Map();
|
|
7827
7795
|
statsConcurrencyTag = Symbol('sfuStatsReporter');
|
|
7828
7796
|
isStopped = false;
|
|
7829
|
-
constructor(sfuClient, { options, clientDetails, subscriber, publisher,
|
|
7797
|
+
constructor(sfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }) {
|
|
7830
7798
|
this.sfuClient = sfuClient;
|
|
7831
7799
|
this.options = options;
|
|
7832
7800
|
this.subscriber = subscriber;
|
|
7833
7801
|
this.publisher = publisher;
|
|
7834
|
-
this.microphone = microphone;
|
|
7835
|
-
this.camera = camera;
|
|
7836
|
-
this.state = state;
|
|
7837
7802
|
this.tracer = tracer;
|
|
7838
7803
|
this.unifiedSessionId = unifiedSessionId;
|
|
7839
7804
|
const { sdk, browser } = clientDetails;
|
|
@@ -7847,35 +7812,6 @@ class SfuStatsReporter {
|
|
|
7847
7812
|
`${browser?.name || ''}-${browser?.version || ''}` ||
|
|
7848
7813
|
'N/A';
|
|
7849
7814
|
}
|
|
7850
|
-
observeDevice = (device, kind) => {
|
|
7851
|
-
const { browserPermissionState$ } = device.state;
|
|
7852
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
7853
|
-
this.unsubscribeDevicePermissionsSubscription = createSubscription(combineLatest([browserPermissionState$, this.state.ownCapabilities$]), ([browserPermissionState, ownCapabilities]) => {
|
|
7854
|
-
// cleanup the previous listDevices() subscription in case
|
|
7855
|
-
// permissions or capabilities have changed.
|
|
7856
|
-
// we will subscribe again if everything is in order.
|
|
7857
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
7858
|
-
const hasCapability = kind === 'mic'
|
|
7859
|
-
? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
|
|
7860
|
-
: ownCapabilities.includes(OwnCapability.SEND_VIDEO);
|
|
7861
|
-
if (browserPermissionState !== 'granted' || !hasCapability) {
|
|
7862
|
-
this.inputDevices.set(kind, {
|
|
7863
|
-
currentDevice: '',
|
|
7864
|
-
availableDevices: [],
|
|
7865
|
-
isPermitted: false,
|
|
7866
|
-
});
|
|
7867
|
-
return;
|
|
7868
|
-
}
|
|
7869
|
-
this.unsubscribeListDevicesSubscription = createSubscription(combineLatest([device.listDevices(), device.state.selectedDevice$]), ([devices, deviceId]) => {
|
|
7870
|
-
const selected = devices.find((d) => d.deviceId === deviceId);
|
|
7871
|
-
this.inputDevices.set(kind, {
|
|
7872
|
-
currentDevice: selected?.label || deviceId || '',
|
|
7873
|
-
availableDevices: devices.map((d) => d.label),
|
|
7874
|
-
isPermitted: true,
|
|
7875
|
-
});
|
|
7876
|
-
});
|
|
7877
|
-
});
|
|
7878
|
-
};
|
|
7879
7815
|
sendConnectionTime = (connectionTimeSeconds) => {
|
|
7880
7816
|
this.sendTelemetryData({
|
|
7881
7817
|
data: {
|
|
@@ -7910,7 +7846,7 @@ class SfuStatsReporter {
|
|
|
7910
7846
|
this.subscriber.stats.takeSample(),
|
|
7911
7847
|
this.publisher?.stats.takeSample(),
|
|
7912
7848
|
]);
|
|
7913
|
-
send = (
|
|
7849
|
+
send = (subscriberPerfStats, publisherPerfStats, telemetry) => {
|
|
7914
7850
|
// serialize sends so overlapping ones can't race on the trace buffers or
|
|
7915
7851
|
// deliver an older delta after a newer one already succeeded. Not gated by
|
|
7916
7852
|
// `isStopped`: an explicit final flush must still deliver after stop().
|
|
@@ -7928,8 +7864,8 @@ class SfuStatsReporter {
|
|
|
7928
7864
|
this.subscriber.stats.clearPendingDeltas();
|
|
7929
7865
|
}
|
|
7930
7866
|
let pubPending = [];
|
|
7931
|
-
if (pubTracer &&
|
|
7932
|
-
pubPending = this.publisher
|
|
7867
|
+
if (pubTracer && this.publisher) {
|
|
7868
|
+
pubPending = this.publisher.stats.getPendingDeltas();
|
|
7933
7869
|
}
|
|
7934
7870
|
else {
|
|
7935
7871
|
this.publisher?.stats.clearPendingDeltas();
|
|
@@ -7951,17 +7887,9 @@ class SfuStatsReporter {
|
|
|
7951
7887
|
sdk: this.sdkName,
|
|
7952
7888
|
sdkVersion: this.sdkVersion,
|
|
7953
7889
|
webrtcVersion: this.webRTCVersion,
|
|
7954
|
-
subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
|
|
7955
|
-
publisherStats: publisherStats
|
|
7956
|
-
? JSON.stringify(flatten(publisherStats.stats))
|
|
7957
|
-
: '[]',
|
|
7958
|
-
subscriberRtcStats: '',
|
|
7959
|
-
publisherRtcStats: '',
|
|
7960
7890
|
rtcStats: JSON.stringify(traces),
|
|
7961
|
-
encodeStats:
|
|
7962
|
-
decodeStats:
|
|
7963
|
-
audioDevices: this.inputDevices.get('mic'),
|
|
7964
|
-
videoDevices: this.inputDevices.get('camera'),
|
|
7891
|
+
encodeStats: publisherPerfStats ?? [],
|
|
7892
|
+
decodeStats: subscriberPerfStats,
|
|
7965
7893
|
unifiedSessionId: this.unifiedSessionId,
|
|
7966
7894
|
deviceState: getDeviceState(),
|
|
7967
7895
|
telemetry,
|
|
@@ -7975,8 +7903,8 @@ class SfuStatsReporter {
|
|
|
7975
7903
|
// delivery confirmed: advance the delivery baseline for each chain.
|
|
7976
7904
|
if (subTracer)
|
|
7977
7905
|
this.subscriber.stats.commitDeltas(subPending);
|
|
7978
|
-
if (pubTracer &&
|
|
7979
|
-
this.publisher
|
|
7906
|
+
if (pubTracer && this.publisher) {
|
|
7907
|
+
this.publisher.stats.commitDeltas(pubPending);
|
|
7980
7908
|
}
|
|
7981
7909
|
}
|
|
7982
7910
|
catch (err) {
|
|
@@ -7998,8 +7926,8 @@ class SfuStatsReporter {
|
|
|
7998
7926
|
run = async (telemetry) => {
|
|
7999
7927
|
if (this.isStopped)
|
|
8000
7928
|
return;
|
|
8001
|
-
const [
|
|
8002
|
-
await this.send(
|
|
7929
|
+
const [subscriberPerfStats, publisherPerfStats] = await this.sample();
|
|
7930
|
+
await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
|
|
8003
7931
|
};
|
|
8004
7932
|
scheduleNextReport = () => {
|
|
8005
7933
|
const intervals = [1500, 3000, 3000, 5000];
|
|
@@ -8020,8 +7948,6 @@ class SfuStatsReporter {
|
|
|
8020
7948
|
start = () => {
|
|
8021
7949
|
if (this.options.reporting_interval_ms <= 0)
|
|
8022
7950
|
return;
|
|
8023
|
-
this.observeDevice(this.microphone, 'mic');
|
|
8024
|
-
this.observeDevice(this.camera, 'camera');
|
|
8025
7951
|
this.isStopped = false;
|
|
8026
7952
|
this.reportCount = 0;
|
|
8027
7953
|
clearInterval(this.intervalId);
|
|
@@ -8030,11 +7956,6 @@ class SfuStatsReporter {
|
|
|
8030
7956
|
};
|
|
8031
7957
|
stop = () => {
|
|
8032
7958
|
this.isStopped = true;
|
|
8033
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
8034
|
-
this.unsubscribeDevicePermissionsSubscription = undefined;
|
|
8035
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
8036
|
-
this.unsubscribeListDevicesSubscription = undefined;
|
|
8037
|
-
this.inputDevices.clear();
|
|
8038
7959
|
clearInterval(this.intervalId);
|
|
8039
7960
|
this.intervalId = undefined;
|
|
8040
7961
|
clearTimeout(this.timeoutId);
|
|
@@ -8055,8 +7976,8 @@ class SfuStatsReporter {
|
|
|
8055
7976
|
return;
|
|
8056
7977
|
try {
|
|
8057
7978
|
const [sample] = await timeboxed([this.sample()], 2000);
|
|
8058
|
-
const [
|
|
8059
|
-
this.send(
|
|
7979
|
+
const [subscriberPerfStats, publisherPerfStats] = sample;
|
|
7980
|
+
this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
|
|
8060
7981
|
this.logger.warn('Failed to flush report stats', err);
|
|
8061
7982
|
});
|
|
8062
7983
|
}
|
|
@@ -8183,9 +8104,9 @@ class StatsTracer {
|
|
|
8183
8104
|
this.maxPendingDeltas = maxPendingDeltas;
|
|
8184
8105
|
}
|
|
8185
8106
|
/**
|
|
8186
|
-
* Samples the RTCPeerConnection: returns the
|
|
8187
|
-
*
|
|
8188
|
-
*
|
|
8107
|
+
* Samples the RTCPeerConnection: returns the derived performance stats and
|
|
8108
|
+
* appends the delta-compressed sample to the un-acked delivery chain
|
|
8109
|
+
* (retrieved via `getPendingDeltas`).
|
|
8189
8110
|
*
|
|
8190
8111
|
* @internal
|
|
8191
8112
|
*/
|
|
@@ -8210,7 +8131,7 @@ class StatsTracer {
|
|
|
8210
8131
|
this.pendingDeltas.push({ delta, ts: now });
|
|
8211
8132
|
this.frameTimeHistory = this.frameTimeHistory.slice(-2);
|
|
8212
8133
|
this.fpsHistory = this.fpsHistory.slice(-2);
|
|
8213
|
-
return
|
|
8134
|
+
return performanceStats;
|
|
8214
8135
|
});
|
|
8215
8136
|
};
|
|
8216
8137
|
/**
|
|
@@ -8530,10 +8451,12 @@ class BasePeerConnection {
|
|
|
8530
8451
|
sfuClient;
|
|
8531
8452
|
onReconnectionNeeded;
|
|
8532
8453
|
onIceConnected;
|
|
8454
|
+
onNoLocalCandidates;
|
|
8533
8455
|
onPeerConnectionStateChange;
|
|
8534
8456
|
onRemoteTrackUnmute;
|
|
8535
8457
|
iceRestartDelay;
|
|
8536
8458
|
iceHasEverConnected = false;
|
|
8459
|
+
localCandidatesGathered = 0;
|
|
8537
8460
|
iceRestartTimeout;
|
|
8538
8461
|
preConnectStuckTimeout;
|
|
8539
8462
|
isIceRestarting = false;
|
|
@@ -8547,7 +8470,7 @@ class BasePeerConnection {
|
|
|
8547
8470
|
/**
|
|
8548
8471
|
* Constructs a new `BasePeerConnection` instance.
|
|
8549
8472
|
*/
|
|
8550
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8473
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8551
8474
|
this.peerType = peerType;
|
|
8552
8475
|
this.sfuClient = sfuClient;
|
|
8553
8476
|
this.state = state;
|
|
@@ -8558,6 +8481,7 @@ class BasePeerConnection {
|
|
|
8558
8481
|
this.tag = tag;
|
|
8559
8482
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
8560
8483
|
this.onIceConnected = onIceConnected;
|
|
8484
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
8561
8485
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
8562
8486
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
8563
8487
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
@@ -8598,6 +8522,7 @@ class BasePeerConnection {
|
|
|
8598
8522
|
this.preConnectStuckTimeout = undefined;
|
|
8599
8523
|
this.onReconnectionNeeded = undefined;
|
|
8600
8524
|
this.onIceConnected = undefined;
|
|
8525
|
+
this.onNoLocalCandidates = undefined;
|
|
8601
8526
|
this.onPeerConnectionStateChange = undefined;
|
|
8602
8527
|
this.onRemoteTrackUnmute = undefined;
|
|
8603
8528
|
this.isDisposed = true;
|
|
@@ -8701,6 +8626,17 @@ class BasePeerConnection {
|
|
|
8701
8626
|
getTrackType = (trackId) => {
|
|
8702
8627
|
return this.trackIdToTrackType.get(trackId);
|
|
8703
8628
|
};
|
|
8629
|
+
/**
|
|
8630
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
8631
|
+
* once during the lifetime of this peer connection.
|
|
8632
|
+
*/
|
|
8633
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
8634
|
+
/**
|
|
8635
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
8636
|
+
* producing a single local candidate.
|
|
8637
|
+
*/
|
|
8638
|
+
hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
|
|
8639
|
+
this.localCandidatesGathered === 0;
|
|
8704
8640
|
/**
|
|
8705
8641
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
8706
8642
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -8723,6 +8659,8 @@ class BasePeerConnection {
|
|
|
8723
8659
|
this.logger.debug('null ice candidate');
|
|
8724
8660
|
return;
|
|
8725
8661
|
}
|
|
8662
|
+
if (candidate.candidate)
|
|
8663
|
+
this.localCandidatesGathered++;
|
|
8726
8664
|
const iceCandidate = toJSON(candidate);
|
|
8727
8665
|
this.sfuClient
|
|
8728
8666
|
.iceTrickle({ peerType: this.peerType, iceCandidate })
|
|
@@ -8787,6 +8725,17 @@ class BasePeerConnection {
|
|
|
8787
8725
|
}
|
|
8788
8726
|
};
|
|
8789
8727
|
handleConnectionStateUpdate = (state) => {
|
|
8728
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
8729
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
8730
|
+
// ahead of the guards below so a connection established while offline,
|
|
8731
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
8732
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
8733
|
+
// recovered, not merely on SFU join.
|
|
8734
|
+
if ((state === 'connected' || state === 'completed') &&
|
|
8735
|
+
!this.iceHasEverConnected) {
|
|
8736
|
+
this.iceHasEverConnected = true;
|
|
8737
|
+
this.onIceConnected?.(this.peerType);
|
|
8738
|
+
}
|
|
8790
8739
|
const { callingState } = this.state;
|
|
8791
8740
|
if (callingState === CallingState.OFFLINE)
|
|
8792
8741
|
return;
|
|
@@ -8850,14 +8799,6 @@ class BasePeerConnection {
|
|
|
8850
8799
|
break;
|
|
8851
8800
|
case 'connected':
|
|
8852
8801
|
case 'completed':
|
|
8853
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
8854
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
8855
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
8856
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
8857
|
-
if (!this.iceHasEverConnected) {
|
|
8858
|
-
this.iceHasEverConnected = true;
|
|
8859
|
-
this.onIceConnected?.(this.peerType);
|
|
8860
|
-
}
|
|
8861
8802
|
// clear any scheduled restartICE since the connection is healthy
|
|
8862
8803
|
if (this.iceRestartTimeout) {
|
|
8863
8804
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -8883,7 +8824,17 @@ class BasePeerConnection {
|
|
|
8883
8824
|
* Handles the ICE gathering state change event.
|
|
8884
8825
|
*/
|
|
8885
8826
|
onIceGatherChange = () => {
|
|
8886
|
-
|
|
8827
|
+
const state = this.pc.iceGatheringState;
|
|
8828
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
8829
|
+
if (state === 'gathering') {
|
|
8830
|
+
this.localCandidatesGathered = 0;
|
|
8831
|
+
}
|
|
8832
|
+
else if (state === 'complete' &&
|
|
8833
|
+
this.localCandidatesGathered === 0 &&
|
|
8834
|
+
!this.iceHasEverConnected) {
|
|
8835
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
8836
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
8837
|
+
}
|
|
8887
8838
|
};
|
|
8888
8839
|
/**
|
|
8889
8840
|
* Handles the signaling state change event.
|
|
@@ -10759,15 +10710,16 @@ class StreamSfuClient {
|
|
|
10759
10710
|
/**
|
|
10760
10711
|
* Decides what a ringing call should do next, based on the current call state.
|
|
10761
10712
|
*
|
|
10762
|
-
* The `call.accepted
|
|
10763
|
-
*
|
|
10764
|
-
* handlers rely on `CallState.updateFromEvent`, which runs
|
|
10765
|
-
* poller applies the polled ring state itself
|
|
10713
|
+
* The `call.accepted` and `call.rejected` handlers, the ring state poller and
|
|
10714
|
+
* the rewatch after a WS reconnect all run this. They differ only in how the
|
|
10715
|
+
* state got there: the handlers rely on `CallState.updateFromEvent`, which runs
|
|
10716
|
+
* before them, while the poller applies the polled ring state itself and the
|
|
10717
|
+
* rewatch applies the queried call state.
|
|
10766
10718
|
*
|
|
10767
10719
|
* @param call the call to reconcile.
|
|
10768
|
-
* @param joinSource
|
|
10769
|
-
*
|
|
10770
|
-
*
|
|
10720
|
+
* @param joinSource what triggered this run, reported on the caller's join:
|
|
10721
|
+
* `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
|
|
10722
|
+
* rewatch (both come from an API response).
|
|
10771
10723
|
* @returns whether the ring reached a terminal state. A failed join is not
|
|
10772
10724
|
* terminal: the caller should keep trying while the ring is open.
|
|
10773
10725
|
*/
|
|
@@ -10822,10 +10774,16 @@ const reconcileAsCaller = async (call, joinSource) => {
|
|
|
10822
10774
|
return false;
|
|
10823
10775
|
};
|
|
10824
10776
|
// the current user's own accept or reject, on this or another device, is
|
|
10825
|
-
// handled by `resolveOwnRingOutcome`, and `call.ended` by
|
|
10777
|
+
// handled by `resolveOwnRingOutcome`, and a live `call.ended` by
|
|
10778
|
+
// `watchCallEnded`. An end missed while offline is handled here.
|
|
10826
10779
|
const reconcileAsCallee = async (call) => {
|
|
10827
|
-
const
|
|
10828
|
-
|
|
10780
|
+
const { session, endedAt, createdBy } = call.state;
|
|
10781
|
+
if (endedAt || session?.ended_at) {
|
|
10782
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
10783
|
+
return leave(call, { reason: 'ended', message: 'ring: call ended' });
|
|
10784
|
+
}
|
|
10785
|
+
const createdById = createdBy?.id;
|
|
10786
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
10829
10787
|
if (createdById && rejectedBy[createdById]) {
|
|
10830
10788
|
call.logger.info('ring: the caller cancelled, leaving');
|
|
10831
10789
|
return leave(call, {
|
|
@@ -10874,7 +10832,7 @@ class RingStatePoller {
|
|
|
10874
10832
|
unsubscribe = [];
|
|
10875
10833
|
constructor(call, options = {}) {
|
|
10876
10834
|
this.call = call;
|
|
10877
|
-
this.startAfterMs = options.startAfterMs ??
|
|
10835
|
+
this.startAfterMs = options.startAfterMs ?? 9_000;
|
|
10878
10836
|
this.intervalMs = options.intervalMs ?? 5_000;
|
|
10879
10837
|
}
|
|
10880
10838
|
/**
|
|
@@ -11539,12 +11497,33 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
11539
11497
|
};
|
|
11540
11498
|
|
|
11541
11499
|
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
11500
|
+
const toElementState = (element) => {
|
|
11501
|
+
const stream = element.srcObject;
|
|
11502
|
+
const [track] = stream?.getAudioTracks?.() ?? [];
|
|
11503
|
+
return {
|
|
11504
|
+
paused: element.paused,
|
|
11505
|
+
muted: element.muted,
|
|
11506
|
+
volume: Math.round(element.volume * 100) / 100,
|
|
11507
|
+
readyState: element.readyState,
|
|
11508
|
+
sinkId: element.sinkId,
|
|
11509
|
+
track: track
|
|
11510
|
+
? {
|
|
11511
|
+
enabled: track.enabled,
|
|
11512
|
+
muted: track.muted,
|
|
11513
|
+
readyState: track.readyState,
|
|
11514
|
+
}
|
|
11515
|
+
: null,
|
|
11516
|
+
};
|
|
11517
|
+
};
|
|
11542
11518
|
/**
|
|
11543
11519
|
* Tracks audio element bindings and periodically warns about
|
|
11544
11520
|
* remote participants whose audio streams have no bound element.
|
|
11521
|
+
* On the same tick, it traces the playback state of the bound elements,
|
|
11522
|
+
* limited to the bindings whose state changed since the previous trace.
|
|
11545
11523
|
*/
|
|
11546
11524
|
class AudioBindingsWatchdog {
|
|
11547
11525
|
bindings = new Map();
|
|
11526
|
+
tracedStates = new Map();
|
|
11548
11527
|
enabled = true;
|
|
11549
11528
|
watchdogInterval;
|
|
11550
11529
|
unsubscribeCallingState;
|
|
@@ -11555,8 +11534,6 @@ class AudioBindingsWatchdog {
|
|
|
11555
11534
|
this.tracer = tracer;
|
|
11556
11535
|
this.state = state;
|
|
11557
11536
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
11558
|
-
if (!this.enabled)
|
|
11559
|
-
return;
|
|
11560
11537
|
if (callingState !== CallingState.JOINED) {
|
|
11561
11538
|
this.stop();
|
|
11562
11539
|
}
|
|
@@ -11585,15 +11562,15 @@ class AudioBindingsWatchdog {
|
|
|
11585
11562
|
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
11586
11563
|
};
|
|
11587
11564
|
/**
|
|
11588
|
-
* Enables or disables the
|
|
11589
|
-
*
|
|
11565
|
+
* Enables or disables the dangling binding warnings.
|
|
11566
|
+
* Bindings are still tracked and their playback state is still traced.
|
|
11590
11567
|
*/
|
|
11591
11568
|
setEnabled = (enabled) => {
|
|
11592
11569
|
this.enabled = enabled;
|
|
11593
11570
|
if (enabled) {
|
|
11594
11571
|
this.start();
|
|
11595
11572
|
}
|
|
11596
|
-
else {
|
|
11573
|
+
else if (this.state.callingState !== CallingState.JOINED) {
|
|
11597
11574
|
this.stop();
|
|
11598
11575
|
}
|
|
11599
11576
|
};
|
|
@@ -11603,33 +11580,61 @@ class AudioBindingsWatchdog {
|
|
|
11603
11580
|
dispose = () => {
|
|
11604
11581
|
this.stop();
|
|
11605
11582
|
this.bindings.clear();
|
|
11583
|
+
this.tracedStates.clear();
|
|
11606
11584
|
this.unsubscribeCallingState();
|
|
11607
11585
|
};
|
|
11608
11586
|
start = () => {
|
|
11609
11587
|
clearInterval(this.watchdogInterval);
|
|
11610
11588
|
this.watchdogInterval = setInterval(() => {
|
|
11611
|
-
|
|
11612
|
-
|
|
11613
|
-
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
11589
|
+
if (this.enabled)
|
|
11590
|
+
this.warnAboutDanglingBindings();
|
|
11591
|
+
this.traceBindingStates();
|
|
11592
|
+
}, 3000);
|
|
11593
|
+
};
|
|
11594
|
+
warnAboutDanglingBindings = () => {
|
|
11595
|
+
const danglingUserIds = [];
|
|
11596
|
+
for (const p of this.state.participants) {
|
|
11597
|
+
if (p.isLocalParticipant)
|
|
11598
|
+
continue;
|
|
11599
|
+
const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
|
|
11600
|
+
if (audioStream &&
|
|
11601
|
+
publishedTracks.includes(TrackType.AUDIO) &&
|
|
11602
|
+
!this.bindings.has(toBindingKey(sessionId))) {
|
|
11603
|
+
danglingUserIds.push(userId);
|
|
11626
11604
|
}
|
|
11627
|
-
if (
|
|
11628
|
-
|
|
11629
|
-
this.
|
|
11630
|
-
|
|
11605
|
+
if (screenShareAudioStream &&
|
|
11606
|
+
publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
|
|
11607
|
+
!this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
|
|
11608
|
+
danglingUserIds.push(userId);
|
|
11631
11609
|
}
|
|
11632
|
-
}
|
|
11610
|
+
}
|
|
11611
|
+
if (danglingUserIds.length > 0) {
|
|
11612
|
+
const key = 'audioBinding.danglingWarning';
|
|
11613
|
+
this.tracer.traceOnce(key, key, danglingUserIds);
|
|
11614
|
+
this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
|
|
11615
|
+
}
|
|
11616
|
+
};
|
|
11617
|
+
traceBindingStates = () => {
|
|
11618
|
+
const changes = {};
|
|
11619
|
+
let hasChanges = false;
|
|
11620
|
+
for (const [key, element] of this.bindings) {
|
|
11621
|
+
const state = toElementState(element);
|
|
11622
|
+
const serialized = JSON.stringify(state);
|
|
11623
|
+
if (this.tracedStates.get(key) === serialized)
|
|
11624
|
+
continue;
|
|
11625
|
+
this.tracedStates.set(key, serialized);
|
|
11626
|
+
changes[key] = state;
|
|
11627
|
+
hasChanges = true;
|
|
11628
|
+
}
|
|
11629
|
+
for (const key of this.tracedStates.keys()) {
|
|
11630
|
+
if (this.bindings.has(key))
|
|
11631
|
+
continue;
|
|
11632
|
+
this.tracedStates.delete(key);
|
|
11633
|
+
changes[key] = null;
|
|
11634
|
+
hasChanges = true;
|
|
11635
|
+
}
|
|
11636
|
+
if (hasChanges)
|
|
11637
|
+
this.tracer.trace('audioBinding.state', changes);
|
|
11633
11638
|
};
|
|
11634
11639
|
stop = () => {
|
|
11635
11640
|
clearInterval(this.watchdogInterval);
|
|
@@ -11908,7 +11913,9 @@ class TrackSubscriptionManager {
|
|
|
11908
11913
|
class MediaPlaybackWatchdog {
|
|
11909
11914
|
logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
11910
11915
|
kind;
|
|
11916
|
+
traceInfo;
|
|
11911
11917
|
isBlocked;
|
|
11918
|
+
onGiveUp;
|
|
11912
11919
|
element;
|
|
11913
11920
|
tracer;
|
|
11914
11921
|
controller = new AbortController();
|
|
@@ -11920,8 +11927,14 @@ class MediaPlaybackWatchdog {
|
|
|
11920
11927
|
constructor(opts) {
|
|
11921
11928
|
this.element = opts.element;
|
|
11922
11929
|
this.kind = opts.kind;
|
|
11930
|
+
this.traceInfo = {
|
|
11931
|
+
kind: opts.kind,
|
|
11932
|
+
sessionId: opts.sessionId,
|
|
11933
|
+
trackType: opts.trackType,
|
|
11934
|
+
};
|
|
11923
11935
|
this.tracer = opts.tracer;
|
|
11924
11936
|
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
11937
|
+
this.onGiveUp = opts.onGiveUp ?? (() => { });
|
|
11925
11938
|
this.attach();
|
|
11926
11939
|
}
|
|
11927
11940
|
attach = () => {
|
|
@@ -11939,6 +11952,23 @@ class MediaPlaybackWatchdog {
|
|
|
11939
11952
|
this.controller.abort();
|
|
11940
11953
|
this.clearTimers();
|
|
11941
11954
|
};
|
|
11955
|
+
/**
|
|
11956
|
+
* Starts a fresh recovery cycle, e.g. after a media interruption ended.
|
|
11957
|
+
* Unlike a regular pause-triggered recovery, it also tries elements that
|
|
11958
|
+
* were skipped as blocked or not ready, since those conditions may have
|
|
11959
|
+
* cleared together with the interruption.
|
|
11960
|
+
*/
|
|
11961
|
+
rearm = () => {
|
|
11962
|
+
if (this.disposed)
|
|
11963
|
+
return;
|
|
11964
|
+
this.clearTimers();
|
|
11965
|
+
this.attempt = 0;
|
|
11966
|
+
this.stopped = false;
|
|
11967
|
+
const { paused, srcObject, ended } = this.element;
|
|
11968
|
+
if (!paused || !srcObject || ended)
|
|
11969
|
+
return;
|
|
11970
|
+
this.pendingTimer = setTimeout(this.attemptPlay, 0);
|
|
11971
|
+
};
|
|
11942
11972
|
clearTimers = () => {
|
|
11943
11973
|
if (this.pendingTimer)
|
|
11944
11974
|
clearTimeout(this.pendingTimer);
|
|
@@ -11960,17 +11990,34 @@ class MediaPlaybackWatchdog {
|
|
|
11960
11990
|
if (this.element.paused)
|
|
11961
11991
|
return;
|
|
11962
11992
|
this.tracer.trace('mediaPlayback.recover.success', {
|
|
11963
|
-
|
|
11993
|
+
...this.traceInfo,
|
|
11964
11994
|
attempts: this.attempt,
|
|
11965
11995
|
});
|
|
11966
11996
|
this.attempt = 0;
|
|
11967
11997
|
this.stopped = false;
|
|
11968
11998
|
};
|
|
11999
|
+
/**
|
|
12000
|
+
* Media elements backed by a MediaStream fire `suspend` routinely (after
|
|
12001
|
+
* the initial load, on source swaps, on layer switches), so a `suspend`
|
|
12002
|
+
* that needs no recovery is ignored without a trace. An explicit `pause`
|
|
12003
|
+
* is rare and always traced, along with the reason recovery was skipped.
|
|
12004
|
+
*/
|
|
11969
12005
|
onPauseOrSuspend = (event) => {
|
|
11970
|
-
if (this.disposed || this.stopped)
|
|
12006
|
+
if (this.disposed || this.stopped || this.pendingTimer)
|
|
12007
|
+
return;
|
|
12008
|
+
const skipReason = this.computeSkipReason();
|
|
12009
|
+
if (skipReason) {
|
|
12010
|
+
if (event.type === 'pause') {
|
|
12011
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
12012
|
+
...this.traceInfo,
|
|
12013
|
+
reason: event.type,
|
|
12014
|
+
skipped: skipReason,
|
|
12015
|
+
});
|
|
12016
|
+
}
|
|
11971
12017
|
return;
|
|
12018
|
+
}
|
|
11972
12019
|
this.tracer.trace('mediaPlayback.paused', {
|
|
11973
|
-
|
|
12020
|
+
...this.traceInfo,
|
|
11974
12021
|
reason: event.type,
|
|
11975
12022
|
});
|
|
11976
12023
|
if (this.settleTimer)
|
|
@@ -11984,7 +12031,7 @@ class MediaPlaybackWatchdog {
|
|
|
11984
12031
|
const skipReason = this.computeSkipReason();
|
|
11985
12032
|
if (skipReason) {
|
|
11986
12033
|
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
11987
|
-
|
|
12034
|
+
...this.traceInfo,
|
|
11988
12035
|
reason: skipReason,
|
|
11989
12036
|
});
|
|
11990
12037
|
return;
|
|
@@ -11993,9 +12040,10 @@ class MediaPlaybackWatchdog {
|
|
|
11993
12040
|
this.stopped = true;
|
|
11994
12041
|
this.clearTimers();
|
|
11995
12042
|
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
11996
|
-
|
|
12043
|
+
...this.traceInfo,
|
|
11997
12044
|
attempts: this.attempt,
|
|
11998
12045
|
});
|
|
12046
|
+
this.onGiveUp();
|
|
11999
12047
|
return;
|
|
12000
12048
|
}
|
|
12001
12049
|
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
@@ -12023,7 +12071,7 @@ class MediaPlaybackWatchdog {
|
|
|
12023
12071
|
return;
|
|
12024
12072
|
this.attempt += 1;
|
|
12025
12073
|
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
12026
|
-
|
|
12074
|
+
...this.traceInfo,
|
|
12027
12075
|
attempt: this.attempt,
|
|
12028
12076
|
});
|
|
12029
12077
|
try {
|
|
@@ -12038,6 +12086,41 @@ class MediaPlaybackWatchdog {
|
|
|
12038
12086
|
};
|
|
12039
12087
|
}
|
|
12040
12088
|
|
|
12089
|
+
/**
|
|
12090
|
+
* Checks whether media playback and capture are likely interrupted: the page
|
|
12091
|
+
* is hidden, or WebKit reports the audio session as interrupted (a phone
|
|
12092
|
+
* call, Siri, or another app taking over audio).
|
|
12093
|
+
*/
|
|
12094
|
+
const isMediaInterrupted = () => {
|
|
12095
|
+
if (typeof document === 'undefined')
|
|
12096
|
+
return false;
|
|
12097
|
+
return (document.visibilityState === 'hidden' ||
|
|
12098
|
+
navigator.audioSession?.state === 'interrupted');
|
|
12099
|
+
};
|
|
12100
|
+
/**
|
|
12101
|
+
* Invokes `onResumed` every time the page leaves a media interruption,
|
|
12102
|
+
* as defined by {@link isMediaInterrupted}.
|
|
12103
|
+
*
|
|
12104
|
+
* @param onResumed the callback to invoke.
|
|
12105
|
+
* @returns a function that stops watching.
|
|
12106
|
+
*/
|
|
12107
|
+
const watchMediaInterruptions = (onResumed) => {
|
|
12108
|
+
if (typeof document === 'undefined')
|
|
12109
|
+
return () => { };
|
|
12110
|
+
let interrupted = isMediaInterrupted();
|
|
12111
|
+
const check = () => {
|
|
12112
|
+
const wasInterrupted = interrupted;
|
|
12113
|
+
interrupted = isMediaInterrupted();
|
|
12114
|
+
if (wasInterrupted && !interrupted)
|
|
12115
|
+
onResumed();
|
|
12116
|
+
};
|
|
12117
|
+
const controller = new AbortController();
|
|
12118
|
+
const { signal } = controller;
|
|
12119
|
+
document.addEventListener('visibilitychange', check, { signal });
|
|
12120
|
+
navigator.audioSession?.addEventListener('statechange', check, { signal });
|
|
12121
|
+
return () => controller.abort();
|
|
12122
|
+
};
|
|
12123
|
+
|
|
12041
12124
|
/**
|
|
12042
12125
|
* A manager class that handles dynascale related tasks like:
|
|
12043
12126
|
*
|
|
@@ -12051,6 +12134,9 @@ class DynascaleManager {
|
|
|
12051
12134
|
tracer;
|
|
12052
12135
|
useWebAudio = false;
|
|
12053
12136
|
audioContext;
|
|
12137
|
+
playbackWatchdogs = new Set();
|
|
12138
|
+
stopWatchingInterruptions;
|
|
12139
|
+
stopTracingAudioSession;
|
|
12054
12140
|
trackSubscriptionManager;
|
|
12055
12141
|
blockedAudioTracker;
|
|
12056
12142
|
/**
|
|
@@ -12067,6 +12153,10 @@ class DynascaleManager {
|
|
|
12067
12153
|
* Closes the audio context if it was created.
|
|
12068
12154
|
*/
|
|
12069
12155
|
dispose = async () => {
|
|
12156
|
+
this.stopWatchingInterruptions?.();
|
|
12157
|
+
this.stopWatchingInterruptions = undefined;
|
|
12158
|
+
this.stopTracingAudioSession?.();
|
|
12159
|
+
this.stopTracingAudioSession = undefined;
|
|
12070
12160
|
const context = this.audioContext;
|
|
12071
12161
|
if (context && context.state !== 'closed') {
|
|
12072
12162
|
document.removeEventListener('click', this.resumeAudioContext);
|
|
@@ -12206,10 +12296,12 @@ class DynascaleManager {
|
|
|
12206
12296
|
// without prior user interaction:
|
|
12207
12297
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
12208
12298
|
videoElement.muted = true;
|
|
12209
|
-
const playbackWatchdog =
|
|
12299
|
+
const playbackWatchdog = this.createPlaybackWatchdog({
|
|
12210
12300
|
element: videoElement,
|
|
12211
12301
|
kind: 'video',
|
|
12212
12302
|
tracer: this.tracer,
|
|
12303
|
+
sessionId,
|
|
12304
|
+
trackType,
|
|
12213
12305
|
});
|
|
12214
12306
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
12215
12307
|
const streamSubscription = participant$
|
|
@@ -12237,7 +12329,7 @@ class DynascaleManager {
|
|
|
12237
12329
|
publishedTracksSubscription?.unsubscribe();
|
|
12238
12330
|
streamSubscription.unsubscribe();
|
|
12239
12331
|
resizeObserver?.disconnect();
|
|
12240
|
-
|
|
12332
|
+
this.disposePlaybackWatchdog(playbackWatchdog);
|
|
12241
12333
|
};
|
|
12242
12334
|
};
|
|
12243
12335
|
/**
|
|
@@ -12255,9 +12347,10 @@ class DynascaleManager {
|
|
|
12255
12347
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
12256
12348
|
if (!participant || participant.isLocalParticipant)
|
|
12257
12349
|
return;
|
|
12350
|
+
this.traceAudioSessionState();
|
|
12258
12351
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
12259
12352
|
const updateSinkId = (deviceId, audioContext) => {
|
|
12260
|
-
if (!deviceId)
|
|
12353
|
+
if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
|
|
12261
12354
|
return;
|
|
12262
12355
|
if ('setSinkId' in audioElement) {
|
|
12263
12356
|
audioElement.setSinkId(deviceId).catch((e) => {
|
|
@@ -12274,7 +12367,9 @@ class DynascaleManager {
|
|
|
12274
12367
|
let sourceNode = undefined;
|
|
12275
12368
|
let gainNode = undefined;
|
|
12276
12369
|
let audioWatchdog = undefined;
|
|
12370
|
+
let blockedByGiveUp = false;
|
|
12277
12371
|
const clearBlockedAudio = () => {
|
|
12372
|
+
blockedByGiveUp = false;
|
|
12278
12373
|
if (!this.blockedAudioTracker.isBlocked(audioElement))
|
|
12279
12374
|
return;
|
|
12280
12375
|
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
@@ -12282,15 +12377,18 @@ class DynascaleManager {
|
|
|
12282
12377
|
audioElement.addEventListener('playing', clearBlockedAudio);
|
|
12283
12378
|
const isAudioTrack = trackType === 'audioTrack';
|
|
12284
12379
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
12380
|
+
let sourceChangeTimer;
|
|
12285
12381
|
const updateMediaStreamSubscription = participant$
|
|
12286
12382
|
.pipe(distinctUntilKeyChanged(trackKey))
|
|
12287
12383
|
.subscribe((p) => {
|
|
12288
12384
|
const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
|
|
12289
12385
|
if (audioElement.srcObject === source)
|
|
12290
12386
|
return;
|
|
12291
|
-
|
|
12387
|
+
clearTimeout(sourceChangeTimer);
|
|
12388
|
+
sourceChangeTimer = setTimeout(() => {
|
|
12389
|
+
sourceChangeTimer = undefined;
|
|
12292
12390
|
audioElement.srcObject = source ?? null;
|
|
12293
|
-
audioWatchdog
|
|
12391
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
12294
12392
|
audioWatchdog = undefined;
|
|
12295
12393
|
if (!source) {
|
|
12296
12394
|
clearBlockedAudio();
|
|
@@ -12321,15 +12419,25 @@ class DynascaleManager {
|
|
|
12321
12419
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
12322
12420
|
if (e.name === 'NotAllowedError') {
|
|
12323
12421
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
12422
|
+
blockedByGiveUp = false;
|
|
12324
12423
|
this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
|
|
12325
12424
|
}
|
|
12326
12425
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
12327
12426
|
});
|
|
12328
|
-
audioWatchdog =
|
|
12427
|
+
audioWatchdog = this.createPlaybackWatchdog({
|
|
12329
12428
|
element: audioElement,
|
|
12330
12429
|
kind: 'audio',
|
|
12331
12430
|
tracer: this.tracer,
|
|
12332
|
-
|
|
12431
|
+
sessionId,
|
|
12432
|
+
trackType,
|
|
12433
|
+
// only an autoplay-policy block needs a user gesture; a give-up
|
|
12434
|
+
// block is for the UI and must not stop a re-armed cycle
|
|
12435
|
+
isBlocked: () => !blockedByGiveUp &&
|
|
12436
|
+
this.blockedAudioTracker.isBlocked(audioElement),
|
|
12437
|
+
onGiveUp: () => {
|
|
12438
|
+
blockedByGiveUp = true;
|
|
12439
|
+
this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
|
|
12440
|
+
},
|
|
12333
12441
|
});
|
|
12334
12442
|
}
|
|
12335
12443
|
const { selectedDevice } = this.speaker.state;
|
|
@@ -12337,7 +12445,7 @@ class DynascaleManager {
|
|
|
12337
12445
|
updateSinkId(selectedDevice, audioContext);
|
|
12338
12446
|
});
|
|
12339
12447
|
});
|
|
12340
|
-
const sinkIdSubscription = !
|
|
12448
|
+
const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
|
|
12341
12449
|
? null
|
|
12342
12450
|
: this.speaker.state.selectedDevice$.subscribe((deviceId) => {
|
|
12343
12451
|
const audioContext = this.getOrCreateAudioContext();
|
|
@@ -12359,13 +12467,51 @@ class DynascaleManager {
|
|
|
12359
12467
|
sinkIdSubscription?.unsubscribe();
|
|
12360
12468
|
volumeSubscription.unsubscribe();
|
|
12361
12469
|
updateMediaStreamSubscription.unsubscribe();
|
|
12470
|
+
clearTimeout(sourceChangeTimer);
|
|
12362
12471
|
audioElement.srcObject = null;
|
|
12363
12472
|
sourceNode?.disconnect();
|
|
12364
12473
|
gainNode?.disconnect();
|
|
12365
|
-
audioWatchdog
|
|
12474
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
12366
12475
|
audioWatchdog = undefined;
|
|
12367
12476
|
};
|
|
12368
12477
|
};
|
|
12478
|
+
/**
|
|
12479
|
+
* Creates a playback watchdog that is re-armed whenever a media
|
|
12480
|
+
* interruption (backgrounding, phone call, Siri) ends.
|
|
12481
|
+
*/
|
|
12482
|
+
createPlaybackWatchdog = (opts) => {
|
|
12483
|
+
const watchdog = new MediaPlaybackWatchdog(opts);
|
|
12484
|
+
this.playbackWatchdogs.add(watchdog);
|
|
12485
|
+
this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
|
|
12486
|
+
return watchdog;
|
|
12487
|
+
};
|
|
12488
|
+
disposePlaybackWatchdog = (watchdog) => {
|
|
12489
|
+
if (!watchdog)
|
|
12490
|
+
return;
|
|
12491
|
+
watchdog.dispose();
|
|
12492
|
+
this.playbackWatchdogs.delete(watchdog);
|
|
12493
|
+
};
|
|
12494
|
+
rearmPlayback = () => {
|
|
12495
|
+
this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
|
|
12496
|
+
this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
|
|
12497
|
+
};
|
|
12498
|
+
/**
|
|
12499
|
+
* Traces the WebKit audio session state (initial value and changes) in
|
|
12500
|
+
* both playback modes, as it explains most iOS playback interruptions.
|
|
12501
|
+
*/
|
|
12502
|
+
traceAudioSessionState = () => {
|
|
12503
|
+
const audioSession = navigator.audioSession;
|
|
12504
|
+
if (!audioSession || this.stopTracingAudioSession)
|
|
12505
|
+
return;
|
|
12506
|
+
const trace = () => {
|
|
12507
|
+
this.tracer.trace('audioSession.state', audioSession.state);
|
|
12508
|
+
};
|
|
12509
|
+
trace();
|
|
12510
|
+
audioSession.addEventListener('statechange', trace);
|
|
12511
|
+
this.stopTracingAudioSession = () => {
|
|
12512
|
+
audioSession.removeEventListener('statechange', trace);
|
|
12513
|
+
};
|
|
12514
|
+
};
|
|
12369
12515
|
getOrCreateAudioContext = () => {
|
|
12370
12516
|
if (!this.useWebAudio)
|
|
12371
12517
|
return;
|
|
@@ -12388,7 +12534,6 @@ class DynascaleManager {
|
|
|
12388
12534
|
audioSession.type = 'play-and-record';
|
|
12389
12535
|
let isSessionInterrupted = false;
|
|
12390
12536
|
audioSession.addEventListener('statechange', () => {
|
|
12391
|
-
this.tracer.trace('audioSession.state', audioSession.state);
|
|
12392
12537
|
if (audioSession.state === 'interrupted') {
|
|
12393
12538
|
isSessionInterrupted = true;
|
|
12394
12539
|
}
|
|
@@ -12963,10 +13108,17 @@ const getDevices = (permission, kind, tracer) => {
|
|
|
12963
13108
|
/**
|
|
12964
13109
|
* Tells if the browser supports audio output change on 'audio' elements,
|
|
12965
13110
|
* see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
|
|
13111
|
+
*
|
|
13112
|
+
* WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
|
|
13113
|
+
* setSinkId requires a user gesture, a per-device output can run at a
|
|
13114
|
+
* different sample rate than the remote audio mixer (pitched/slowed audio),
|
|
13115
|
+
* and audio goes silent when the selected device disconnects.
|
|
12966
13116
|
*/
|
|
12967
13117
|
const checkIfAudioOutputChangeSupported = () => {
|
|
12968
13118
|
if (typeof document === 'undefined')
|
|
12969
13119
|
return false;
|
|
13120
|
+
if (isWebKit())
|
|
13121
|
+
return false;
|
|
12970
13122
|
const element = document.createElement('audio');
|
|
12971
13123
|
return 'setSinkId' in element;
|
|
12972
13124
|
};
|
|
@@ -13472,6 +13624,7 @@ class DeviceManager {
|
|
|
13472
13624
|
? forceStopOrOptions
|
|
13473
13625
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
13474
13626
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
13627
|
+
this.state.setError(undefined);
|
|
13475
13628
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
13476
13629
|
return;
|
|
13477
13630
|
}
|
|
@@ -13481,6 +13634,7 @@ class DeviceManager {
|
|
|
13481
13634
|
const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
|
|
13482
13635
|
await this.muteStream(stopTracks);
|
|
13483
13636
|
this.state.setStatus('disabled');
|
|
13637
|
+
this.state.setError(undefined);
|
|
13484
13638
|
}
|
|
13485
13639
|
finally {
|
|
13486
13640
|
if (!signal.aborted) {
|
|
@@ -13776,7 +13930,11 @@ class DeviceManager {
|
|
|
13776
13930
|
};
|
|
13777
13931
|
// the rootStream represents the stream coming from the actual device
|
|
13778
13932
|
// e.g. camera or microphone stream
|
|
13933
|
+
this.state.setError(undefined);
|
|
13779
13934
|
rootStreamPromise = this.getSelectedStream(constraints);
|
|
13935
|
+
rootStreamPromise.catch((error) => {
|
|
13936
|
+
this.state.setError(error);
|
|
13937
|
+
});
|
|
13780
13938
|
// we publish the last MediaStream of the chain
|
|
13781
13939
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
13782
13940
|
.then((inputStream) => {
|
|
@@ -13947,9 +14105,22 @@ class DeviceManager {
|
|
|
13947
14105
|
}
|
|
13948
14106
|
const devices = getCurrentValue(this.listDevices()) || [];
|
|
13949
14107
|
const currentDevice = this.findDevice(devices, selectedDevice) ??
|
|
13950
|
-
createSyntheticDevice(selectedDevice, deviceKind);
|
|
14108
|
+
createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
|
|
13951
14109
|
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
13952
14110
|
}
|
|
14111
|
+
/**
|
|
14112
|
+
* Returns the label of the active track if it was captured from the given
|
|
14113
|
+
* device. Used as a fallback when the device list hasn't been enumerated yet,
|
|
14114
|
+
* so the persisted preference still carries the device label.
|
|
14115
|
+
*/
|
|
14116
|
+
getActiveTrackLabel = (deviceId) => {
|
|
14117
|
+
const stream = this.state.rootMediaStream;
|
|
14118
|
+
const tracks = this.trackType === TrackType.AUDIO
|
|
14119
|
+
? stream?.getAudioTracks()
|
|
14120
|
+
: stream?.getVideoTracks();
|
|
14121
|
+
const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
|
|
14122
|
+
return track?.label;
|
|
14123
|
+
};
|
|
13953
14124
|
async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
|
|
13954
14125
|
const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
|
|
13955
14126
|
const preferences = readPreferences(this.devicePersistence.storageKey);
|
|
@@ -14001,6 +14172,7 @@ class DeviceManagerState {
|
|
|
14001
14172
|
rootMediaStreamSubject = new BehaviorSubject(undefined);
|
|
14002
14173
|
selectedDeviceSubject = new BehaviorSubject(undefined);
|
|
14003
14174
|
defaultConstraintsSubject = new BehaviorSubject(undefined);
|
|
14175
|
+
errorSubject = new BehaviorSubject(undefined);
|
|
14004
14176
|
/**
|
|
14005
14177
|
* @internal
|
|
14006
14178
|
*/
|
|
@@ -14035,6 +14207,13 @@ class DeviceManagerState {
|
|
|
14035
14207
|
* The default constraints for the device.
|
|
14036
14208
|
*/
|
|
14037
14209
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
14210
|
+
/**
|
|
14211
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
14212
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
14213
|
+
* For screen share, this includes the user dismissing the picker.
|
|
14214
|
+
* Cleared when the device is started again or disabled.
|
|
14215
|
+
*/
|
|
14216
|
+
error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
|
|
14038
14217
|
/**
|
|
14039
14218
|
* An observable that will emit `true` if browser/system permission
|
|
14040
14219
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -14102,6 +14281,19 @@ class DeviceManagerState {
|
|
|
14102
14281
|
get rootMediaStream() {
|
|
14103
14282
|
return getCurrentValue(this.rootMediaStream$);
|
|
14104
14283
|
}
|
|
14284
|
+
/**
|
|
14285
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
14286
|
+
*/
|
|
14287
|
+
get error() {
|
|
14288
|
+
return getCurrentValue(this.error$);
|
|
14289
|
+
}
|
|
14290
|
+
/**
|
|
14291
|
+
* @internal
|
|
14292
|
+
* @param error the error to set.
|
|
14293
|
+
*/
|
|
14294
|
+
setError(error) {
|
|
14295
|
+
setCurrentValue(this.errorSubject, error);
|
|
14296
|
+
}
|
|
14105
14297
|
/**
|
|
14106
14298
|
* @internal
|
|
14107
14299
|
* @param status
|
|
@@ -14683,9 +14875,11 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14683
14875
|
this.call.state.callingState$,
|
|
14684
14876
|
this.call.state.ownCapabilities$,
|
|
14685
14877
|
this.state.selectedDevice$,
|
|
14686
|
-
this.state.status$,
|
|
14687
14878
|
this.state.browserPermissionState$,
|
|
14688
|
-
|
|
14879
|
+
// only a trigger: queued handlers can run after the status changed
|
|
14880
|
+
// again, so the live status is read below
|
|
14881
|
+
this.state.status$,
|
|
14882
|
+
]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
14689
14883
|
try {
|
|
14690
14884
|
if (callingState === CallingState.LEFT) {
|
|
14691
14885
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -14700,7 +14894,7 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14700
14894
|
return;
|
|
14701
14895
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
14702
14896
|
const hasPermission = await this.hasPermission(permissionState);
|
|
14703
|
-
if (hasPermission && status !== 'enabled') {
|
|
14897
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
14704
14898
|
this.setMutedRecordingPrepared(true);
|
|
14705
14899
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
14706
14900
|
}
|
|
@@ -15000,6 +15194,52 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15000
15194
|
await this.call.ensureMediaFactory();
|
|
15001
15195
|
return getAudioStream(constraints, this.call.tracer);
|
|
15002
15196
|
}
|
|
15197
|
+
/**
|
|
15198
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
15199
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
15200
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
15201
|
+
* profiles.
|
|
15202
|
+
*/
|
|
15203
|
+
async muteStream(stopTracks = true) {
|
|
15204
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
15205
|
+
const clone = track?.readyState === 'live' &&
|
|
15206
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
15207
|
+
this.canHandOffMic()
|
|
15208
|
+
? track.clone()
|
|
15209
|
+
: undefined;
|
|
15210
|
+
try {
|
|
15211
|
+
await super.muteStream(stopTracks);
|
|
15212
|
+
}
|
|
15213
|
+
catch (err) {
|
|
15214
|
+
clone?.stop();
|
|
15215
|
+
throw err;
|
|
15216
|
+
}
|
|
15217
|
+
if (!clone)
|
|
15218
|
+
return;
|
|
15219
|
+
// the mic, the call, or the selected device may have changed while
|
|
15220
|
+
// unpublishing
|
|
15221
|
+
const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
|
|
15222
|
+
if (clone.readyState !== 'live' ||
|
|
15223
|
+
!this.canHandOffMic() ||
|
|
15224
|
+
deviceId !== this.state.selectedDevice) {
|
|
15225
|
+
clone.stop();
|
|
15226
|
+
return;
|
|
15227
|
+
}
|
|
15228
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
15229
|
+
clone.stop();
|
|
15230
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
15231
|
+
});
|
|
15232
|
+
}
|
|
15233
|
+
/**
|
|
15234
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
15235
|
+
*/
|
|
15236
|
+
canHandOffMic = () => {
|
|
15237
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
15238
|
+
return (!isReactNative() &&
|
|
15239
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
15240
|
+
callingState === CallingState.JOINED &&
|
|
15241
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO));
|
|
15242
|
+
};
|
|
15003
15243
|
doSetAudioBitrateProfile(profile) {
|
|
15004
15244
|
this.setDefaultConstraints({
|
|
15005
15245
|
...this.state.defaultConstraints,
|
|
@@ -15019,10 +15259,13 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15019
15259
|
}
|
|
15020
15260
|
}
|
|
15021
15261
|
}
|
|
15022
|
-
async startSpeakingWhileMutedDetection(deviceId) {
|
|
15262
|
+
async startSpeakingWhileMutedDetection(deviceId, track) {
|
|
15023
15263
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
15024
|
-
if (this.soundDetectorCleanup &&
|
|
15264
|
+
if (this.soundDetectorCleanup &&
|
|
15265
|
+
this.soundDetectorDeviceId === deviceId) {
|
|
15266
|
+
track?.stop();
|
|
15025
15267
|
return;
|
|
15268
|
+
}
|
|
15026
15269
|
await this.teardownSpeakingWhileMutedDetection();
|
|
15027
15270
|
if (isReactNative()) {
|
|
15028
15271
|
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
@@ -15038,11 +15281,14 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15038
15281
|
};
|
|
15039
15282
|
}
|
|
15040
15283
|
else {
|
|
15041
|
-
//
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15284
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
15285
|
+
// connected to publisher
|
|
15286
|
+
const stream = track
|
|
15287
|
+
? new MediaStream([track])
|
|
15288
|
+
: await this.getStream({
|
|
15289
|
+
...this.state.defaultConstraints,
|
|
15290
|
+
deviceId: { exact: deviceId },
|
|
15291
|
+
});
|
|
15046
15292
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
15047
15293
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
15048
15294
|
});
|
|
@@ -15322,7 +15568,7 @@ class SpeakerManager {
|
|
|
15322
15568
|
}
|
|
15323
15569
|
async applyWeb() {
|
|
15324
15570
|
const { enabled, storageKey } = this.devicePersistence;
|
|
15325
|
-
if (!enabled)
|
|
15571
|
+
if (!enabled || !this.state.isDeviceSelectionSupported)
|
|
15326
15572
|
return;
|
|
15327
15573
|
const preferences = readPreferences(storageKey);
|
|
15328
15574
|
const preferenceList = toPreferenceList(preferences.speaker);
|
|
@@ -15598,6 +15844,14 @@ class Call {
|
|
|
15598
15844
|
networkAvailableTask;
|
|
15599
15845
|
// (10 attempts per rolling 120 s window).
|
|
15600
15846
|
rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
15847
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
15848
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
15849
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
15850
|
+
rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
15851
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
15852
|
+
// gathered no local ICE candidates.
|
|
15853
|
+
joinTasks = new Set();
|
|
15854
|
+
noLocalCandidatesCheckScheduled = false;
|
|
15601
15855
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
15602
15856
|
// failures where ICE never reached `connected`/`completed`.
|
|
15603
15857
|
maxIceFailuresWithoutConnect = 2;
|
|
@@ -15991,6 +16245,7 @@ class Call {
|
|
|
15991
16245
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
15992
16246
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
15993
16247
|
this.rejoinRateLimiter.reset();
|
|
16248
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
15994
16249
|
this.iceFailuresWithoutConnect = 0;
|
|
15995
16250
|
this.consecutiveNegotiationFailures = 0;
|
|
15996
16251
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -16386,6 +16641,16 @@ class Call {
|
|
|
16386
16641
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16387
16642
|
*/
|
|
16388
16643
|
doJoin = async (data) => {
|
|
16644
|
+
const task = this.performJoin(data);
|
|
16645
|
+
this.joinTasks.add(task);
|
|
16646
|
+
try {
|
|
16647
|
+
await task;
|
|
16648
|
+
}
|
|
16649
|
+
finally {
|
|
16650
|
+
this.joinTasks.delete(task);
|
|
16651
|
+
}
|
|
16652
|
+
};
|
|
16653
|
+
performJoin = async (data) => {
|
|
16389
16654
|
const connectStartTime = Date.now();
|
|
16390
16655
|
const callingState = this.state.callingState;
|
|
16391
16656
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -16743,6 +17008,9 @@ class Call {
|
|
|
16743
17008
|
// "ICE never connected" failure budget can be cleared.
|
|
16744
17009
|
this.iceFailuresWithoutConnect = 0;
|
|
16745
17010
|
},
|
|
17011
|
+
onNoLocalCandidates: () => {
|
|
17012
|
+
this.checkNoLocalCandidates();
|
|
17013
|
+
},
|
|
16746
17014
|
onPeerConnectionStateChange: (event) => {
|
|
16747
17015
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
16748
17016
|
},
|
|
@@ -16785,15 +17053,65 @@ class Call {
|
|
|
16785
17053
|
options: statsOptions,
|
|
16786
17054
|
subscriber: this.subscriber,
|
|
16787
17055
|
publisher: this.publisher,
|
|
16788
|
-
microphone: this.microphone,
|
|
16789
|
-
camera: this.camera,
|
|
16790
|
-
state: this.state,
|
|
16791
17056
|
tracer: this.tracer,
|
|
16792
17057
|
unifiedSessionId,
|
|
16793
17058
|
});
|
|
16794
17059
|
this.sfuStatsReporter.start();
|
|
16795
17060
|
}
|
|
16796
17061
|
};
|
|
17062
|
+
/**
|
|
17063
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
17064
|
+
* connections never connected and one of them completed ICE gathering
|
|
17065
|
+
* without a single local candidate, which means WebRTC networking is
|
|
17066
|
+
* blocked on this device.
|
|
17067
|
+
*
|
|
17068
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
17069
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
17070
|
+
* against the peer connections current at that time.
|
|
17071
|
+
*/
|
|
17072
|
+
checkNoLocalCandidates = () => {
|
|
17073
|
+
if (this.noLocalCandidatesCheckScheduled)
|
|
17074
|
+
return;
|
|
17075
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
17076
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
17077
|
+
const leaveGeneration = this.leaveGeneration;
|
|
17078
|
+
this.waitForJoinsAndReconnects()
|
|
17079
|
+
.then(() => {
|
|
17080
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17081
|
+
if (this.leaveGeneration !== leaveGeneration)
|
|
17082
|
+
return;
|
|
17083
|
+
this.checkNoLocalCandidates();
|
|
17084
|
+
})
|
|
17085
|
+
.catch((err) => {
|
|
17086
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17087
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
17088
|
+
});
|
|
17089
|
+
return;
|
|
17090
|
+
}
|
|
17091
|
+
if (this.state.callingState !== CallingState.JOINED)
|
|
17092
|
+
return;
|
|
17093
|
+
// without network, gathering legitimately yields no candidates
|
|
17094
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false)
|
|
17095
|
+
return;
|
|
17096
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
17097
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
|
|
17098
|
+
return;
|
|
17099
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
|
|
17100
|
+
return;
|
|
17101
|
+
this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
|
|
17102
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17103
|
+
});
|
|
17104
|
+
};
|
|
17105
|
+
/**
|
|
17106
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
17107
|
+
*/
|
|
17108
|
+
waitForJoinsAndReconnects = async () => {
|
|
17109
|
+
while (this.joinTasks.size > 0 ||
|
|
17110
|
+
hasPending(this.reconnectConcurrencyTag)) {
|
|
17111
|
+
await Promise.allSettled(this.joinTasks);
|
|
17112
|
+
await settled(this.reconnectConcurrencyTag);
|
|
17113
|
+
}
|
|
17114
|
+
};
|
|
16797
17115
|
/**
|
|
16798
17116
|
* Retrieves credentials for joining the call.
|
|
16799
17117
|
*
|
|
@@ -16992,6 +17310,14 @@ class Call {
|
|
|
16992
17310
|
return;
|
|
16993
17311
|
}
|
|
16994
17312
|
}
|
|
17313
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
17314
|
+
// toward the long-window backstop.
|
|
17315
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
17316
|
+
this.rejoinLongWindowRateLimiter &&
|
|
17317
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()) {
|
|
17318
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
17319
|
+
return;
|
|
17320
|
+
}
|
|
16995
17321
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
16996
17322
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
16997
17323
|
this.reconnectAttempts++;
|
|
@@ -17210,7 +17536,13 @@ class Call {
|
|
|
17210
17536
|
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
17211
17537
|
});
|
|
17212
17538
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
17213
|
-
|
|
17539
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
17540
|
+
// still report errors about its own (replaced) session, and none may
|
|
17541
|
+
// while `leave()` tears the call down.
|
|
17542
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
17543
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
17544
|
+
if (!this.sfuClient)
|
|
17545
|
+
return;
|
|
17214
17546
|
const { reconnectStrategy: strategy, error } = e;
|
|
17215
17547
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
17216
17548
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -17233,7 +17565,16 @@ class Call {
|
|
|
17233
17565
|
});
|
|
17234
17566
|
}
|
|
17235
17567
|
else {
|
|
17236
|
-
|
|
17568
|
+
// A media transport failure on a session whose peer connections
|
|
17569
|
+
// never connected is an "ICE never connected" failure, and must
|
|
17570
|
+
// count toward the unsupported-network budget.
|
|
17571
|
+
const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
17572
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
17573
|
+
!this.subscriber?.hasIceEverConnected();
|
|
17574
|
+
const reason = neverConnected
|
|
17575
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
17576
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
17577
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
17237
17578
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17238
17579
|
});
|
|
17239
17580
|
}
|
|
@@ -18297,9 +18638,11 @@ class Call {
|
|
|
18297
18638
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
18298
18639
|
*
|
|
18299
18640
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
18300
|
-
* Both arguments are clamped to a minimum of 1.
|
|
18641
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
18642
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
18301
18643
|
*/
|
|
18302
18644
|
setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
18645
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
18303
18646
|
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
18304
18647
|
};
|
|
18305
18648
|
/**
|
|
@@ -19637,11 +19980,11 @@ class StreamClient {
|
|
|
19637
19980
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19638
19981
|
};
|
|
19639
19982
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19640
|
-
"1.
|
|
19983
|
+
"1.62.0";
|
|
19641
19984
|
getUserAgent = () => {
|
|
19642
19985
|
if (!this.cachedUserAgent) {
|
|
19643
19986
|
const { clientAppIdentifier = {} } = this.options;
|
|
19644
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19987
|
+
const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
|
|
19645
19988
|
this.cachedUserAgent = [
|
|
19646
19989
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19647
19990
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -20581,6 +20924,10 @@ class StreamVideoClient {
|
|
|
20581
20924
|
const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
|
|
20582
20925
|
if (call) {
|
|
20583
20926
|
call.updateFromCallStateResponse(c);
|
|
20927
|
+
// ring events missed while the WS was down aren't replayed
|
|
20928
|
+
reconcileRingState(call, 'ring-poll-api').catch((err) => {
|
|
20929
|
+
call.logger.error('Failed to reconcile the ring state after rewatch', err);
|
|
20930
|
+
});
|
|
20584
20931
|
}
|
|
20585
20932
|
}
|
|
20586
20933
|
return;
|