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