@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.cjs.js
CHANGED
|
@@ -2685,18 +2685,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
|
|
|
2685
2685
|
constructor() {
|
|
2686
2686
|
super('stream.video.sfu.signal.SendStatsRequest', [
|
|
2687
2687
|
{ no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
2688
|
-
{
|
|
2689
|
-
no: 2,
|
|
2690
|
-
name: 'subscriber_stats',
|
|
2691
|
-
kind: 'scalar',
|
|
2692
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2693
|
-
},
|
|
2694
|
-
{
|
|
2695
|
-
no: 3,
|
|
2696
|
-
name: 'publisher_stats',
|
|
2697
|
-
kind: 'scalar',
|
|
2698
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2699
|
-
},
|
|
2700
2688
|
{
|
|
2701
2689
|
no: 4,
|
|
2702
2690
|
name: 'webrtc_version',
|
|
@@ -2710,8 +2698,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
|
|
|
2710
2698
|
kind: 'scalar',
|
|
2711
2699
|
T: 9 /*ScalarType.STRING*/,
|
|
2712
2700
|
},
|
|
2713
|
-
{ no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
|
|
2714
|
-
{ no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
|
|
2715
2701
|
{
|
|
2716
2702
|
no: 9,
|
|
2717
2703
|
name: 'android',
|
|
@@ -2728,18 +2714,6 @@ class SendStatsRequest$Type extends runtime.MessageType {
|
|
|
2728
2714
|
},
|
|
2729
2715
|
{ no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
|
|
2730
2716
|
{ no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
|
|
2731
|
-
{
|
|
2732
|
-
no: 13,
|
|
2733
|
-
name: 'subscriber_rtc_stats',
|
|
2734
|
-
kind: 'scalar',
|
|
2735
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2736
|
-
},
|
|
2737
|
-
{
|
|
2738
|
-
no: 14,
|
|
2739
|
-
name: 'publisher_rtc_stats',
|
|
2740
|
-
kind: 'scalar',
|
|
2741
|
-
T: 9 /*ScalarType.STRING*/,
|
|
2742
|
-
},
|
|
2743
2717
|
{ no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
2744
2718
|
{
|
|
2745
2719
|
no: 16,
|
|
@@ -5101,7 +5075,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5101
5075
|
}
|
|
5102
5076
|
};
|
|
5103
5077
|
|
|
5104
|
-
const version = "1.
|
|
5078
|
+
const version = "1.62.0";
|
|
5105
5079
|
const [major, minor, patch] = version.split('.');
|
|
5106
5080
|
let sdkInfo = {
|
|
5107
5081
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7829,30 +7803,21 @@ class SfuStatsReporter {
|
|
|
7829
7803
|
sfuClient;
|
|
7830
7804
|
subscriber;
|
|
7831
7805
|
publisher;
|
|
7832
|
-
microphone;
|
|
7833
|
-
camera;
|
|
7834
|
-
state;
|
|
7835
7806
|
tracer;
|
|
7836
7807
|
unifiedSessionId;
|
|
7837
7808
|
intervalId;
|
|
7838
7809
|
timeoutId;
|
|
7839
7810
|
reportCount = 0;
|
|
7840
|
-
unsubscribeDevicePermissionsSubscription;
|
|
7841
|
-
unsubscribeListDevicesSubscription;
|
|
7842
7811
|
sdkName;
|
|
7843
7812
|
sdkVersion;
|
|
7844
7813
|
webRTCVersion;
|
|
7845
|
-
inputDevices = new Map();
|
|
7846
7814
|
statsConcurrencyTag = Symbol('sfuStatsReporter');
|
|
7847
7815
|
isStopped = false;
|
|
7848
|
-
constructor(sfuClient, { options, clientDetails, subscriber, publisher,
|
|
7816
|
+
constructor(sfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }) {
|
|
7849
7817
|
this.sfuClient = sfuClient;
|
|
7850
7818
|
this.options = options;
|
|
7851
7819
|
this.subscriber = subscriber;
|
|
7852
7820
|
this.publisher = publisher;
|
|
7853
|
-
this.microphone = microphone;
|
|
7854
|
-
this.camera = camera;
|
|
7855
|
-
this.state = state;
|
|
7856
7821
|
this.tracer = tracer;
|
|
7857
7822
|
this.unifiedSessionId = unifiedSessionId;
|
|
7858
7823
|
const { sdk, browser } = clientDetails;
|
|
@@ -7866,35 +7831,6 @@ class SfuStatsReporter {
|
|
|
7866
7831
|
`${browser?.name || ''}-${browser?.version || ''}` ||
|
|
7867
7832
|
'N/A';
|
|
7868
7833
|
}
|
|
7869
|
-
observeDevice = (device, kind) => {
|
|
7870
|
-
const { browserPermissionState$ } = device.state;
|
|
7871
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
7872
|
-
this.unsubscribeDevicePermissionsSubscription = createSubscription(rxjs.combineLatest([browserPermissionState$, this.state.ownCapabilities$]), ([browserPermissionState, ownCapabilities]) => {
|
|
7873
|
-
// cleanup the previous listDevices() subscription in case
|
|
7874
|
-
// permissions or capabilities have changed.
|
|
7875
|
-
// we will subscribe again if everything is in order.
|
|
7876
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
7877
|
-
const hasCapability = kind === 'mic'
|
|
7878
|
-
? ownCapabilities.includes(OwnCapability.SEND_AUDIO)
|
|
7879
|
-
: ownCapabilities.includes(OwnCapability.SEND_VIDEO);
|
|
7880
|
-
if (browserPermissionState !== 'granted' || !hasCapability) {
|
|
7881
|
-
this.inputDevices.set(kind, {
|
|
7882
|
-
currentDevice: '',
|
|
7883
|
-
availableDevices: [],
|
|
7884
|
-
isPermitted: false,
|
|
7885
|
-
});
|
|
7886
|
-
return;
|
|
7887
|
-
}
|
|
7888
|
-
this.unsubscribeListDevicesSubscription = createSubscription(rxjs.combineLatest([device.listDevices(), device.state.selectedDevice$]), ([devices, deviceId]) => {
|
|
7889
|
-
const selected = devices.find((d) => d.deviceId === deviceId);
|
|
7890
|
-
this.inputDevices.set(kind, {
|
|
7891
|
-
currentDevice: selected?.label || deviceId || '',
|
|
7892
|
-
availableDevices: devices.map((d) => d.label),
|
|
7893
|
-
isPermitted: true,
|
|
7894
|
-
});
|
|
7895
|
-
});
|
|
7896
|
-
});
|
|
7897
|
-
};
|
|
7898
7834
|
sendConnectionTime = (connectionTimeSeconds) => {
|
|
7899
7835
|
this.sendTelemetryData({
|
|
7900
7836
|
data: {
|
|
@@ -7929,7 +7865,7 @@ class SfuStatsReporter {
|
|
|
7929
7865
|
this.subscriber.stats.takeSample(),
|
|
7930
7866
|
this.publisher?.stats.takeSample(),
|
|
7931
7867
|
]);
|
|
7932
|
-
send = (
|
|
7868
|
+
send = (subscriberPerfStats, publisherPerfStats, telemetry) => {
|
|
7933
7869
|
// serialize sends so overlapping ones can't race on the trace buffers or
|
|
7934
7870
|
// deliver an older delta after a newer one already succeeded. Not gated by
|
|
7935
7871
|
// `isStopped`: an explicit final flush must still deliver after stop().
|
|
@@ -7947,8 +7883,8 @@ class SfuStatsReporter {
|
|
|
7947
7883
|
this.subscriber.stats.clearPendingDeltas();
|
|
7948
7884
|
}
|
|
7949
7885
|
let pubPending = [];
|
|
7950
|
-
if (pubTracer &&
|
|
7951
|
-
pubPending = this.publisher
|
|
7886
|
+
if (pubTracer && this.publisher) {
|
|
7887
|
+
pubPending = this.publisher.stats.getPendingDeltas();
|
|
7952
7888
|
}
|
|
7953
7889
|
else {
|
|
7954
7890
|
this.publisher?.stats.clearPendingDeltas();
|
|
@@ -7970,17 +7906,9 @@ class SfuStatsReporter {
|
|
|
7970
7906
|
sdk: this.sdkName,
|
|
7971
7907
|
sdkVersion: this.sdkVersion,
|
|
7972
7908
|
webrtcVersion: this.webRTCVersion,
|
|
7973
|
-
subscriberStats: JSON.stringify(flatten(subscriberStats.stats)),
|
|
7974
|
-
publisherStats: publisherStats
|
|
7975
|
-
? JSON.stringify(flatten(publisherStats.stats))
|
|
7976
|
-
: '[]',
|
|
7977
|
-
subscriberRtcStats: '',
|
|
7978
|
-
publisherRtcStats: '',
|
|
7979
7909
|
rtcStats: JSON.stringify(traces),
|
|
7980
|
-
encodeStats:
|
|
7981
|
-
decodeStats:
|
|
7982
|
-
audioDevices: this.inputDevices.get('mic'),
|
|
7983
|
-
videoDevices: this.inputDevices.get('camera'),
|
|
7910
|
+
encodeStats: publisherPerfStats ?? [],
|
|
7911
|
+
decodeStats: subscriberPerfStats,
|
|
7984
7912
|
unifiedSessionId: this.unifiedSessionId,
|
|
7985
7913
|
deviceState: getDeviceState(),
|
|
7986
7914
|
telemetry,
|
|
@@ -7994,8 +7922,8 @@ class SfuStatsReporter {
|
|
|
7994
7922
|
// delivery confirmed: advance the delivery baseline for each chain.
|
|
7995
7923
|
if (subTracer)
|
|
7996
7924
|
this.subscriber.stats.commitDeltas(subPending);
|
|
7997
|
-
if (pubTracer &&
|
|
7998
|
-
this.publisher
|
|
7925
|
+
if (pubTracer && this.publisher) {
|
|
7926
|
+
this.publisher.stats.commitDeltas(pubPending);
|
|
7999
7927
|
}
|
|
8000
7928
|
}
|
|
8001
7929
|
catch (err) {
|
|
@@ -8017,8 +7945,8 @@ class SfuStatsReporter {
|
|
|
8017
7945
|
run = async (telemetry) => {
|
|
8018
7946
|
if (this.isStopped)
|
|
8019
7947
|
return;
|
|
8020
|
-
const [
|
|
8021
|
-
await this.send(
|
|
7948
|
+
const [subscriberPerfStats, publisherPerfStats] = await this.sample();
|
|
7949
|
+
await this.send(subscriberPerfStats, publisherPerfStats, telemetry);
|
|
8022
7950
|
};
|
|
8023
7951
|
scheduleNextReport = () => {
|
|
8024
7952
|
const intervals = [1500, 3000, 3000, 5000];
|
|
@@ -8039,8 +7967,6 @@ class SfuStatsReporter {
|
|
|
8039
7967
|
start = () => {
|
|
8040
7968
|
if (this.options.reporting_interval_ms <= 0)
|
|
8041
7969
|
return;
|
|
8042
|
-
this.observeDevice(this.microphone, 'mic');
|
|
8043
|
-
this.observeDevice(this.camera, 'camera');
|
|
8044
7970
|
this.isStopped = false;
|
|
8045
7971
|
this.reportCount = 0;
|
|
8046
7972
|
clearInterval(this.intervalId);
|
|
@@ -8049,11 +7975,6 @@ class SfuStatsReporter {
|
|
|
8049
7975
|
};
|
|
8050
7976
|
stop = () => {
|
|
8051
7977
|
this.isStopped = true;
|
|
8052
|
-
this.unsubscribeDevicePermissionsSubscription?.();
|
|
8053
|
-
this.unsubscribeDevicePermissionsSubscription = undefined;
|
|
8054
|
-
this.unsubscribeListDevicesSubscription?.();
|
|
8055
|
-
this.unsubscribeListDevicesSubscription = undefined;
|
|
8056
|
-
this.inputDevices.clear();
|
|
8057
7978
|
clearInterval(this.intervalId);
|
|
8058
7979
|
this.intervalId = undefined;
|
|
8059
7980
|
clearTimeout(this.timeoutId);
|
|
@@ -8074,8 +7995,8 @@ class SfuStatsReporter {
|
|
|
8074
7995
|
return;
|
|
8075
7996
|
try {
|
|
8076
7997
|
const [sample] = await timeboxed([this.sample()], 2000);
|
|
8077
|
-
const [
|
|
8078
|
-
this.send(
|
|
7998
|
+
const [subscriberPerfStats, publisherPerfStats] = sample;
|
|
7999
|
+
this.send(subscriberPerfStats, publisherPerfStats).catch((err) => {
|
|
8079
8000
|
this.logger.warn('Failed to flush report stats', err);
|
|
8080
8001
|
});
|
|
8081
8002
|
}
|
|
@@ -8202,9 +8123,9 @@ class StatsTracer {
|
|
|
8202
8123
|
this.maxPendingDeltas = maxPendingDeltas;
|
|
8203
8124
|
}
|
|
8204
8125
|
/**
|
|
8205
|
-
* Samples the RTCPeerConnection: returns the
|
|
8206
|
-
*
|
|
8207
|
-
*
|
|
8126
|
+
* Samples the RTCPeerConnection: returns the derived performance stats and
|
|
8127
|
+
* appends the delta-compressed sample to the un-acked delivery chain
|
|
8128
|
+
* (retrieved via `getPendingDeltas`).
|
|
8208
8129
|
*
|
|
8209
8130
|
* @internal
|
|
8210
8131
|
*/
|
|
@@ -8229,7 +8150,7 @@ class StatsTracer {
|
|
|
8229
8150
|
this.pendingDeltas.push({ delta, ts: now });
|
|
8230
8151
|
this.frameTimeHistory = this.frameTimeHistory.slice(-2);
|
|
8231
8152
|
this.fpsHistory = this.fpsHistory.slice(-2);
|
|
8232
|
-
return
|
|
8153
|
+
return performanceStats;
|
|
8233
8154
|
});
|
|
8234
8155
|
};
|
|
8235
8156
|
/**
|
|
@@ -8549,10 +8470,12 @@ class BasePeerConnection {
|
|
|
8549
8470
|
sfuClient;
|
|
8550
8471
|
onReconnectionNeeded;
|
|
8551
8472
|
onIceConnected;
|
|
8473
|
+
onNoLocalCandidates;
|
|
8552
8474
|
onPeerConnectionStateChange;
|
|
8553
8475
|
onRemoteTrackUnmute;
|
|
8554
8476
|
iceRestartDelay;
|
|
8555
8477
|
iceHasEverConnected = false;
|
|
8478
|
+
localCandidatesGathered = 0;
|
|
8556
8479
|
iceRestartTimeout;
|
|
8557
8480
|
preConnectStuckTimeout;
|
|
8558
8481
|
isIceRestarting = false;
|
|
@@ -8566,7 +8489,7 @@ class BasePeerConnection {
|
|
|
8566
8489
|
/**
|
|
8567
8490
|
* Constructs a new `BasePeerConnection` instance.
|
|
8568
8491
|
*/
|
|
8569
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8492
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8570
8493
|
this.peerType = peerType;
|
|
8571
8494
|
this.sfuClient = sfuClient;
|
|
8572
8495
|
this.state = state;
|
|
@@ -8577,6 +8500,7 @@ class BasePeerConnection {
|
|
|
8577
8500
|
this.tag = tag;
|
|
8578
8501
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
8579
8502
|
this.onIceConnected = onIceConnected;
|
|
8503
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
8580
8504
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
8581
8505
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
8582
8506
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
@@ -8617,6 +8541,7 @@ class BasePeerConnection {
|
|
|
8617
8541
|
this.preConnectStuckTimeout = undefined;
|
|
8618
8542
|
this.onReconnectionNeeded = undefined;
|
|
8619
8543
|
this.onIceConnected = undefined;
|
|
8544
|
+
this.onNoLocalCandidates = undefined;
|
|
8620
8545
|
this.onPeerConnectionStateChange = undefined;
|
|
8621
8546
|
this.onRemoteTrackUnmute = undefined;
|
|
8622
8547
|
this.isDisposed = true;
|
|
@@ -8720,6 +8645,17 @@ class BasePeerConnection {
|
|
|
8720
8645
|
getTrackType = (trackId) => {
|
|
8721
8646
|
return this.trackIdToTrackType.get(trackId);
|
|
8722
8647
|
};
|
|
8648
|
+
/**
|
|
8649
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
8650
|
+
* once during the lifetime of this peer connection.
|
|
8651
|
+
*/
|
|
8652
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
8653
|
+
/**
|
|
8654
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
8655
|
+
* producing a single local candidate.
|
|
8656
|
+
*/
|
|
8657
|
+
hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
|
|
8658
|
+
this.localCandidatesGathered === 0;
|
|
8723
8659
|
/**
|
|
8724
8660
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
8725
8661
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -8742,6 +8678,8 @@ class BasePeerConnection {
|
|
|
8742
8678
|
this.logger.debug('null ice candidate');
|
|
8743
8679
|
return;
|
|
8744
8680
|
}
|
|
8681
|
+
if (candidate.candidate)
|
|
8682
|
+
this.localCandidatesGathered++;
|
|
8745
8683
|
const iceCandidate = toJSON(candidate);
|
|
8746
8684
|
this.sfuClient
|
|
8747
8685
|
.iceTrickle({ peerType: this.peerType, iceCandidate })
|
|
@@ -8806,6 +8744,17 @@ class BasePeerConnection {
|
|
|
8806
8744
|
}
|
|
8807
8745
|
};
|
|
8808
8746
|
handleConnectionStateUpdate = (state) => {
|
|
8747
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
8748
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
8749
|
+
// ahead of the guards below so a connection established while offline,
|
|
8750
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
8751
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
8752
|
+
// recovered, not merely on SFU join.
|
|
8753
|
+
if ((state === 'connected' || state === 'completed') &&
|
|
8754
|
+
!this.iceHasEverConnected) {
|
|
8755
|
+
this.iceHasEverConnected = true;
|
|
8756
|
+
this.onIceConnected?.(this.peerType);
|
|
8757
|
+
}
|
|
8809
8758
|
const { callingState } = this.state;
|
|
8810
8759
|
if (callingState === exports.CallingState.OFFLINE)
|
|
8811
8760
|
return;
|
|
@@ -8869,14 +8818,6 @@ class BasePeerConnection {
|
|
|
8869
8818
|
break;
|
|
8870
8819
|
case 'connected':
|
|
8871
8820
|
case 'completed':
|
|
8872
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
8873
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
8874
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
8875
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
8876
|
-
if (!this.iceHasEverConnected) {
|
|
8877
|
-
this.iceHasEverConnected = true;
|
|
8878
|
-
this.onIceConnected?.(this.peerType);
|
|
8879
|
-
}
|
|
8880
8821
|
// clear any scheduled restartICE since the connection is healthy
|
|
8881
8822
|
if (this.iceRestartTimeout) {
|
|
8882
8823
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -8902,7 +8843,17 @@ class BasePeerConnection {
|
|
|
8902
8843
|
* Handles the ICE gathering state change event.
|
|
8903
8844
|
*/
|
|
8904
8845
|
onIceGatherChange = () => {
|
|
8905
|
-
|
|
8846
|
+
const state = this.pc.iceGatheringState;
|
|
8847
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
8848
|
+
if (state === 'gathering') {
|
|
8849
|
+
this.localCandidatesGathered = 0;
|
|
8850
|
+
}
|
|
8851
|
+
else if (state === 'complete' &&
|
|
8852
|
+
this.localCandidatesGathered === 0 &&
|
|
8853
|
+
!this.iceHasEverConnected) {
|
|
8854
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
8855
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
8856
|
+
}
|
|
8906
8857
|
};
|
|
8907
8858
|
/**
|
|
8908
8859
|
* Handles the signaling state change event.
|
|
@@ -10778,15 +10729,16 @@ class StreamSfuClient {
|
|
|
10778
10729
|
/**
|
|
10779
10730
|
* Decides what a ringing call should do next, based on the current call state.
|
|
10780
10731
|
*
|
|
10781
|
-
* The `call.accepted
|
|
10782
|
-
*
|
|
10783
|
-
* handlers rely on `CallState.updateFromEvent`, which runs
|
|
10784
|
-
* poller applies the polled ring state itself
|
|
10732
|
+
* The `call.accepted` and `call.rejected` handlers, the ring state poller and
|
|
10733
|
+
* the rewatch after a WS reconnect all run this. They differ only in how the
|
|
10734
|
+
* state got there: the handlers rely on `CallState.updateFromEvent`, which runs
|
|
10735
|
+
* before them, while the poller applies the polled ring state itself and the
|
|
10736
|
+
* rewatch applies the queried call state.
|
|
10785
10737
|
*
|
|
10786
10738
|
* @param call the call to reconcile.
|
|
10787
|
-
* @param joinSource
|
|
10788
|
-
*
|
|
10789
|
-
*
|
|
10739
|
+
* @param joinSource what triggered this run, reported on the caller's join:
|
|
10740
|
+
* `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
|
|
10741
|
+
* rewatch (both come from an API response).
|
|
10790
10742
|
* @returns whether the ring reached a terminal state. A failed join is not
|
|
10791
10743
|
* terminal: the caller should keep trying while the ring is open.
|
|
10792
10744
|
*/
|
|
@@ -10841,10 +10793,16 @@ const reconcileAsCaller = async (call, joinSource) => {
|
|
|
10841
10793
|
return false;
|
|
10842
10794
|
};
|
|
10843
10795
|
// the current user's own accept or reject, on this or another device, is
|
|
10844
|
-
// handled by `resolveOwnRingOutcome`, and `call.ended` by
|
|
10796
|
+
// handled by `resolveOwnRingOutcome`, and a live `call.ended` by
|
|
10797
|
+
// `watchCallEnded`. An end missed while offline is handled here.
|
|
10845
10798
|
const reconcileAsCallee = async (call) => {
|
|
10846
|
-
const
|
|
10847
|
-
|
|
10799
|
+
const { session, endedAt, createdBy } = call.state;
|
|
10800
|
+
if (endedAt || session?.ended_at) {
|
|
10801
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
10802
|
+
return leave(call, { reason: 'ended', message: 'ring: call ended' });
|
|
10803
|
+
}
|
|
10804
|
+
const createdById = createdBy?.id;
|
|
10805
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
10848
10806
|
if (createdById && rejectedBy[createdById]) {
|
|
10849
10807
|
call.logger.info('ring: the caller cancelled, leaving');
|
|
10850
10808
|
return leave(call, {
|
|
@@ -10893,7 +10851,7 @@ class RingStatePoller {
|
|
|
10893
10851
|
unsubscribe = [];
|
|
10894
10852
|
constructor(call, options = {}) {
|
|
10895
10853
|
this.call = call;
|
|
10896
|
-
this.startAfterMs = options.startAfterMs ??
|
|
10854
|
+
this.startAfterMs = options.startAfterMs ?? 9_000;
|
|
10897
10855
|
this.intervalMs = options.intervalMs ?? 5_000;
|
|
10898
10856
|
}
|
|
10899
10857
|
/**
|
|
@@ -11558,12 +11516,33 @@ const registerRingingCallEventHandlers = (call) => {
|
|
|
11558
11516
|
};
|
|
11559
11517
|
|
|
11560
11518
|
const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
|
|
11519
|
+
const toElementState = (element) => {
|
|
11520
|
+
const stream = element.srcObject;
|
|
11521
|
+
const [track] = stream?.getAudioTracks?.() ?? [];
|
|
11522
|
+
return {
|
|
11523
|
+
paused: element.paused,
|
|
11524
|
+
muted: element.muted,
|
|
11525
|
+
volume: Math.round(element.volume * 100) / 100,
|
|
11526
|
+
readyState: element.readyState,
|
|
11527
|
+
sinkId: element.sinkId,
|
|
11528
|
+
track: track
|
|
11529
|
+
? {
|
|
11530
|
+
enabled: track.enabled,
|
|
11531
|
+
muted: track.muted,
|
|
11532
|
+
readyState: track.readyState,
|
|
11533
|
+
}
|
|
11534
|
+
: null,
|
|
11535
|
+
};
|
|
11536
|
+
};
|
|
11561
11537
|
/**
|
|
11562
11538
|
* Tracks audio element bindings and periodically warns about
|
|
11563
11539
|
* remote participants whose audio streams have no bound element.
|
|
11540
|
+
* On the same tick, it traces the playback state of the bound elements,
|
|
11541
|
+
* limited to the bindings whose state changed since the previous trace.
|
|
11564
11542
|
*/
|
|
11565
11543
|
class AudioBindingsWatchdog {
|
|
11566
11544
|
bindings = new Map();
|
|
11545
|
+
tracedStates = new Map();
|
|
11567
11546
|
enabled = true;
|
|
11568
11547
|
watchdogInterval;
|
|
11569
11548
|
unsubscribeCallingState;
|
|
@@ -11574,8 +11553,6 @@ class AudioBindingsWatchdog {
|
|
|
11574
11553
|
this.tracer = tracer;
|
|
11575
11554
|
this.state = state;
|
|
11576
11555
|
this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
|
|
11577
|
-
if (!this.enabled)
|
|
11578
|
-
return;
|
|
11579
11556
|
if (callingState !== exports.CallingState.JOINED) {
|
|
11580
11557
|
this.stop();
|
|
11581
11558
|
}
|
|
@@ -11604,15 +11581,15 @@ class AudioBindingsWatchdog {
|
|
|
11604
11581
|
this.bindings.delete(toBindingKey(sessionId, trackType));
|
|
11605
11582
|
};
|
|
11606
11583
|
/**
|
|
11607
|
-
* Enables or disables the
|
|
11608
|
-
*
|
|
11584
|
+
* Enables or disables the dangling binding warnings.
|
|
11585
|
+
* Bindings are still tracked and their playback state is still traced.
|
|
11609
11586
|
*/
|
|
11610
11587
|
setEnabled = (enabled) => {
|
|
11611
11588
|
this.enabled = enabled;
|
|
11612
11589
|
if (enabled) {
|
|
11613
11590
|
this.start();
|
|
11614
11591
|
}
|
|
11615
|
-
else {
|
|
11592
|
+
else if (this.state.callingState !== exports.CallingState.JOINED) {
|
|
11616
11593
|
this.stop();
|
|
11617
11594
|
}
|
|
11618
11595
|
};
|
|
@@ -11622,33 +11599,61 @@ class AudioBindingsWatchdog {
|
|
|
11622
11599
|
dispose = () => {
|
|
11623
11600
|
this.stop();
|
|
11624
11601
|
this.bindings.clear();
|
|
11602
|
+
this.tracedStates.clear();
|
|
11625
11603
|
this.unsubscribeCallingState();
|
|
11626
11604
|
};
|
|
11627
11605
|
start = () => {
|
|
11628
11606
|
clearInterval(this.watchdogInterval);
|
|
11629
11607
|
this.watchdogInterval = setInterval(() => {
|
|
11630
|
-
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
|
|
11635
|
-
|
|
11636
|
-
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
11640
|
-
|
|
11641
|
-
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
|
|
11608
|
+
if (this.enabled)
|
|
11609
|
+
this.warnAboutDanglingBindings();
|
|
11610
|
+
this.traceBindingStates();
|
|
11611
|
+
}, 3000);
|
|
11612
|
+
};
|
|
11613
|
+
warnAboutDanglingBindings = () => {
|
|
11614
|
+
const danglingUserIds = [];
|
|
11615
|
+
for (const p of this.state.participants) {
|
|
11616
|
+
if (p.isLocalParticipant)
|
|
11617
|
+
continue;
|
|
11618
|
+
const { audioStream, screenShareAudioStream, sessionId, userId, publishedTracks, } = p;
|
|
11619
|
+
if (audioStream &&
|
|
11620
|
+
publishedTracks.includes(TrackType.AUDIO) &&
|
|
11621
|
+
!this.bindings.has(toBindingKey(sessionId))) {
|
|
11622
|
+
danglingUserIds.push(userId);
|
|
11645
11623
|
}
|
|
11646
|
-
if (
|
|
11647
|
-
|
|
11648
|
-
this.
|
|
11649
|
-
|
|
11624
|
+
if (screenShareAudioStream &&
|
|
11625
|
+
publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
|
|
11626
|
+
!this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))) {
|
|
11627
|
+
danglingUserIds.push(userId);
|
|
11650
11628
|
}
|
|
11651
|
-
}
|
|
11629
|
+
}
|
|
11630
|
+
if (danglingUserIds.length > 0) {
|
|
11631
|
+
const key = 'audioBinding.danglingWarning';
|
|
11632
|
+
this.tracer.traceOnce(key, key, danglingUserIds);
|
|
11633
|
+
this.logger.warn(`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`);
|
|
11634
|
+
}
|
|
11635
|
+
};
|
|
11636
|
+
traceBindingStates = () => {
|
|
11637
|
+
const changes = {};
|
|
11638
|
+
let hasChanges = false;
|
|
11639
|
+
for (const [key, element] of this.bindings) {
|
|
11640
|
+
const state = toElementState(element);
|
|
11641
|
+
const serialized = JSON.stringify(state);
|
|
11642
|
+
if (this.tracedStates.get(key) === serialized)
|
|
11643
|
+
continue;
|
|
11644
|
+
this.tracedStates.set(key, serialized);
|
|
11645
|
+
changes[key] = state;
|
|
11646
|
+
hasChanges = true;
|
|
11647
|
+
}
|
|
11648
|
+
for (const key of this.tracedStates.keys()) {
|
|
11649
|
+
if (this.bindings.has(key))
|
|
11650
|
+
continue;
|
|
11651
|
+
this.tracedStates.delete(key);
|
|
11652
|
+
changes[key] = null;
|
|
11653
|
+
hasChanges = true;
|
|
11654
|
+
}
|
|
11655
|
+
if (hasChanges)
|
|
11656
|
+
this.tracer.trace('audioBinding.state', changes);
|
|
11652
11657
|
};
|
|
11653
11658
|
stop = () => {
|
|
11654
11659
|
clearInterval(this.watchdogInterval);
|
|
@@ -11927,7 +11932,9 @@ class TrackSubscriptionManager {
|
|
|
11927
11932
|
class MediaPlaybackWatchdog {
|
|
11928
11933
|
logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
11929
11934
|
kind;
|
|
11935
|
+
traceInfo;
|
|
11930
11936
|
isBlocked;
|
|
11937
|
+
onGiveUp;
|
|
11931
11938
|
element;
|
|
11932
11939
|
tracer;
|
|
11933
11940
|
controller = new AbortController();
|
|
@@ -11939,8 +11946,14 @@ class MediaPlaybackWatchdog {
|
|
|
11939
11946
|
constructor(opts) {
|
|
11940
11947
|
this.element = opts.element;
|
|
11941
11948
|
this.kind = opts.kind;
|
|
11949
|
+
this.traceInfo = {
|
|
11950
|
+
kind: opts.kind,
|
|
11951
|
+
sessionId: opts.sessionId,
|
|
11952
|
+
trackType: opts.trackType,
|
|
11953
|
+
};
|
|
11942
11954
|
this.tracer = opts.tracer;
|
|
11943
11955
|
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
11956
|
+
this.onGiveUp = opts.onGiveUp ?? (() => { });
|
|
11944
11957
|
this.attach();
|
|
11945
11958
|
}
|
|
11946
11959
|
attach = () => {
|
|
@@ -11958,6 +11971,23 @@ class MediaPlaybackWatchdog {
|
|
|
11958
11971
|
this.controller.abort();
|
|
11959
11972
|
this.clearTimers();
|
|
11960
11973
|
};
|
|
11974
|
+
/**
|
|
11975
|
+
* Starts a fresh recovery cycle, e.g. after a media interruption ended.
|
|
11976
|
+
* Unlike a regular pause-triggered recovery, it also tries elements that
|
|
11977
|
+
* were skipped as blocked or not ready, since those conditions may have
|
|
11978
|
+
* cleared together with the interruption.
|
|
11979
|
+
*/
|
|
11980
|
+
rearm = () => {
|
|
11981
|
+
if (this.disposed)
|
|
11982
|
+
return;
|
|
11983
|
+
this.clearTimers();
|
|
11984
|
+
this.attempt = 0;
|
|
11985
|
+
this.stopped = false;
|
|
11986
|
+
const { paused, srcObject, ended } = this.element;
|
|
11987
|
+
if (!paused || !srcObject || ended)
|
|
11988
|
+
return;
|
|
11989
|
+
this.pendingTimer = setTimeout(this.attemptPlay, 0);
|
|
11990
|
+
};
|
|
11961
11991
|
clearTimers = () => {
|
|
11962
11992
|
if (this.pendingTimer)
|
|
11963
11993
|
clearTimeout(this.pendingTimer);
|
|
@@ -11979,17 +12009,34 @@ class MediaPlaybackWatchdog {
|
|
|
11979
12009
|
if (this.element.paused)
|
|
11980
12010
|
return;
|
|
11981
12011
|
this.tracer.trace('mediaPlayback.recover.success', {
|
|
11982
|
-
|
|
12012
|
+
...this.traceInfo,
|
|
11983
12013
|
attempts: this.attempt,
|
|
11984
12014
|
});
|
|
11985
12015
|
this.attempt = 0;
|
|
11986
12016
|
this.stopped = false;
|
|
11987
12017
|
};
|
|
12018
|
+
/**
|
|
12019
|
+
* Media elements backed by a MediaStream fire `suspend` routinely (after
|
|
12020
|
+
* the initial load, on source swaps, on layer switches), so a `suspend`
|
|
12021
|
+
* that needs no recovery is ignored without a trace. An explicit `pause`
|
|
12022
|
+
* is rare and always traced, along with the reason recovery was skipped.
|
|
12023
|
+
*/
|
|
11988
12024
|
onPauseOrSuspend = (event) => {
|
|
11989
|
-
if (this.disposed || this.stopped)
|
|
12025
|
+
if (this.disposed || this.stopped || this.pendingTimer)
|
|
12026
|
+
return;
|
|
12027
|
+
const skipReason = this.computeSkipReason();
|
|
12028
|
+
if (skipReason) {
|
|
12029
|
+
if (event.type === 'pause') {
|
|
12030
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
12031
|
+
...this.traceInfo,
|
|
12032
|
+
reason: event.type,
|
|
12033
|
+
skipped: skipReason,
|
|
12034
|
+
});
|
|
12035
|
+
}
|
|
11990
12036
|
return;
|
|
12037
|
+
}
|
|
11991
12038
|
this.tracer.trace('mediaPlayback.paused', {
|
|
11992
|
-
|
|
12039
|
+
...this.traceInfo,
|
|
11993
12040
|
reason: event.type,
|
|
11994
12041
|
});
|
|
11995
12042
|
if (this.settleTimer)
|
|
@@ -12003,7 +12050,7 @@ class MediaPlaybackWatchdog {
|
|
|
12003
12050
|
const skipReason = this.computeSkipReason();
|
|
12004
12051
|
if (skipReason) {
|
|
12005
12052
|
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
12006
|
-
|
|
12053
|
+
...this.traceInfo,
|
|
12007
12054
|
reason: skipReason,
|
|
12008
12055
|
});
|
|
12009
12056
|
return;
|
|
@@ -12012,9 +12059,10 @@ class MediaPlaybackWatchdog {
|
|
|
12012
12059
|
this.stopped = true;
|
|
12013
12060
|
this.clearTimers();
|
|
12014
12061
|
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
12015
|
-
|
|
12062
|
+
...this.traceInfo,
|
|
12016
12063
|
attempts: this.attempt,
|
|
12017
12064
|
});
|
|
12065
|
+
this.onGiveUp();
|
|
12018
12066
|
return;
|
|
12019
12067
|
}
|
|
12020
12068
|
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
@@ -12042,7 +12090,7 @@ class MediaPlaybackWatchdog {
|
|
|
12042
12090
|
return;
|
|
12043
12091
|
this.attempt += 1;
|
|
12044
12092
|
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
12045
|
-
|
|
12093
|
+
...this.traceInfo,
|
|
12046
12094
|
attempt: this.attempt,
|
|
12047
12095
|
});
|
|
12048
12096
|
try {
|
|
@@ -12057,6 +12105,41 @@ class MediaPlaybackWatchdog {
|
|
|
12057
12105
|
};
|
|
12058
12106
|
}
|
|
12059
12107
|
|
|
12108
|
+
/**
|
|
12109
|
+
* Checks whether media playback and capture are likely interrupted: the page
|
|
12110
|
+
* is hidden, or WebKit reports the audio session as interrupted (a phone
|
|
12111
|
+
* call, Siri, or another app taking over audio).
|
|
12112
|
+
*/
|
|
12113
|
+
const isMediaInterrupted = () => {
|
|
12114
|
+
if (typeof document === 'undefined')
|
|
12115
|
+
return false;
|
|
12116
|
+
return (document.visibilityState === 'hidden' ||
|
|
12117
|
+
navigator.audioSession?.state === 'interrupted');
|
|
12118
|
+
};
|
|
12119
|
+
/**
|
|
12120
|
+
* Invokes `onResumed` every time the page leaves a media interruption,
|
|
12121
|
+
* as defined by {@link isMediaInterrupted}.
|
|
12122
|
+
*
|
|
12123
|
+
* @param onResumed the callback to invoke.
|
|
12124
|
+
* @returns a function that stops watching.
|
|
12125
|
+
*/
|
|
12126
|
+
const watchMediaInterruptions = (onResumed) => {
|
|
12127
|
+
if (typeof document === 'undefined')
|
|
12128
|
+
return () => { };
|
|
12129
|
+
let interrupted = isMediaInterrupted();
|
|
12130
|
+
const check = () => {
|
|
12131
|
+
const wasInterrupted = interrupted;
|
|
12132
|
+
interrupted = isMediaInterrupted();
|
|
12133
|
+
if (wasInterrupted && !interrupted)
|
|
12134
|
+
onResumed();
|
|
12135
|
+
};
|
|
12136
|
+
const controller = new AbortController();
|
|
12137
|
+
const { signal } = controller;
|
|
12138
|
+
document.addEventListener('visibilitychange', check, { signal });
|
|
12139
|
+
navigator.audioSession?.addEventListener('statechange', check, { signal });
|
|
12140
|
+
return () => controller.abort();
|
|
12141
|
+
};
|
|
12142
|
+
|
|
12060
12143
|
/**
|
|
12061
12144
|
* A manager class that handles dynascale related tasks like:
|
|
12062
12145
|
*
|
|
@@ -12070,6 +12153,9 @@ class DynascaleManager {
|
|
|
12070
12153
|
tracer;
|
|
12071
12154
|
useWebAudio = false;
|
|
12072
12155
|
audioContext;
|
|
12156
|
+
playbackWatchdogs = new Set();
|
|
12157
|
+
stopWatchingInterruptions;
|
|
12158
|
+
stopTracingAudioSession;
|
|
12073
12159
|
trackSubscriptionManager;
|
|
12074
12160
|
blockedAudioTracker;
|
|
12075
12161
|
/**
|
|
@@ -12086,6 +12172,10 @@ class DynascaleManager {
|
|
|
12086
12172
|
* Closes the audio context if it was created.
|
|
12087
12173
|
*/
|
|
12088
12174
|
dispose = async () => {
|
|
12175
|
+
this.stopWatchingInterruptions?.();
|
|
12176
|
+
this.stopWatchingInterruptions = undefined;
|
|
12177
|
+
this.stopTracingAudioSession?.();
|
|
12178
|
+
this.stopTracingAudioSession = undefined;
|
|
12089
12179
|
const context = this.audioContext;
|
|
12090
12180
|
if (context && context.state !== 'closed') {
|
|
12091
12181
|
document.removeEventListener('click', this.resumeAudioContext);
|
|
@@ -12225,10 +12315,12 @@ class DynascaleManager {
|
|
|
12225
12315
|
// without prior user interaction:
|
|
12226
12316
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
12227
12317
|
videoElement.muted = true;
|
|
12228
|
-
const playbackWatchdog =
|
|
12318
|
+
const playbackWatchdog = this.createPlaybackWatchdog({
|
|
12229
12319
|
element: videoElement,
|
|
12230
12320
|
kind: 'video',
|
|
12231
12321
|
tracer: this.tracer,
|
|
12322
|
+
sessionId,
|
|
12323
|
+
trackType,
|
|
12232
12324
|
});
|
|
12233
12325
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
12234
12326
|
const streamSubscription = participant$
|
|
@@ -12256,7 +12348,7 @@ class DynascaleManager {
|
|
|
12256
12348
|
publishedTracksSubscription?.unsubscribe();
|
|
12257
12349
|
streamSubscription.unsubscribe();
|
|
12258
12350
|
resizeObserver?.disconnect();
|
|
12259
|
-
|
|
12351
|
+
this.disposePlaybackWatchdog(playbackWatchdog);
|
|
12260
12352
|
};
|
|
12261
12353
|
};
|
|
12262
12354
|
/**
|
|
@@ -12274,9 +12366,10 @@ class DynascaleManager {
|
|
|
12274
12366
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
12275
12367
|
if (!participant || participant.isLocalParticipant)
|
|
12276
12368
|
return;
|
|
12369
|
+
this.traceAudioSessionState();
|
|
12277
12370
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
12278
12371
|
const updateSinkId = (deviceId, audioContext) => {
|
|
12279
|
-
if (!deviceId)
|
|
12372
|
+
if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
|
|
12280
12373
|
return;
|
|
12281
12374
|
if ('setSinkId' in audioElement) {
|
|
12282
12375
|
audioElement.setSinkId(deviceId).catch((e) => {
|
|
@@ -12293,7 +12386,9 @@ class DynascaleManager {
|
|
|
12293
12386
|
let sourceNode = undefined;
|
|
12294
12387
|
let gainNode = undefined;
|
|
12295
12388
|
let audioWatchdog = undefined;
|
|
12389
|
+
let blockedByGiveUp = false;
|
|
12296
12390
|
const clearBlockedAudio = () => {
|
|
12391
|
+
blockedByGiveUp = false;
|
|
12297
12392
|
if (!this.blockedAudioTracker.isBlocked(audioElement))
|
|
12298
12393
|
return;
|
|
12299
12394
|
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
@@ -12301,15 +12396,18 @@ class DynascaleManager {
|
|
|
12301
12396
|
audioElement.addEventListener('playing', clearBlockedAudio);
|
|
12302
12397
|
const isAudioTrack = trackType === 'audioTrack';
|
|
12303
12398
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
12399
|
+
let sourceChangeTimer;
|
|
12304
12400
|
const updateMediaStreamSubscription = participant$
|
|
12305
12401
|
.pipe(rxjs.distinctUntilKeyChanged(trackKey))
|
|
12306
12402
|
.subscribe((p) => {
|
|
12307
12403
|
const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
|
|
12308
12404
|
if (audioElement.srcObject === source)
|
|
12309
12405
|
return;
|
|
12310
|
-
|
|
12406
|
+
clearTimeout(sourceChangeTimer);
|
|
12407
|
+
sourceChangeTimer = setTimeout(() => {
|
|
12408
|
+
sourceChangeTimer = undefined;
|
|
12311
12409
|
audioElement.srcObject = source ?? null;
|
|
12312
|
-
audioWatchdog
|
|
12410
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
12313
12411
|
audioWatchdog = undefined;
|
|
12314
12412
|
if (!source) {
|
|
12315
12413
|
clearBlockedAudio();
|
|
@@ -12340,15 +12438,25 @@ class DynascaleManager {
|
|
|
12340
12438
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
12341
12439
|
if (e.name === 'NotAllowedError') {
|
|
12342
12440
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
12441
|
+
blockedByGiveUp = false;
|
|
12343
12442
|
this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
|
|
12344
12443
|
}
|
|
12345
12444
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
12346
12445
|
});
|
|
12347
|
-
audioWatchdog =
|
|
12446
|
+
audioWatchdog = this.createPlaybackWatchdog({
|
|
12348
12447
|
element: audioElement,
|
|
12349
12448
|
kind: 'audio',
|
|
12350
12449
|
tracer: this.tracer,
|
|
12351
|
-
|
|
12450
|
+
sessionId,
|
|
12451
|
+
trackType,
|
|
12452
|
+
// only an autoplay-policy block needs a user gesture; a give-up
|
|
12453
|
+
// block is for the UI and must not stop a re-armed cycle
|
|
12454
|
+
isBlocked: () => !blockedByGiveUp &&
|
|
12455
|
+
this.blockedAudioTracker.isBlocked(audioElement),
|
|
12456
|
+
onGiveUp: () => {
|
|
12457
|
+
blockedByGiveUp = true;
|
|
12458
|
+
this.blockedAudioTracker.markBlocked(audioElement, true, sessionId);
|
|
12459
|
+
},
|
|
12352
12460
|
});
|
|
12353
12461
|
}
|
|
12354
12462
|
const { selectedDevice } = this.speaker.state;
|
|
@@ -12356,7 +12464,7 @@ class DynascaleManager {
|
|
|
12356
12464
|
updateSinkId(selectedDevice, audioContext);
|
|
12357
12465
|
});
|
|
12358
12466
|
});
|
|
12359
|
-
const sinkIdSubscription = !
|
|
12467
|
+
const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
|
|
12360
12468
|
? null
|
|
12361
12469
|
: this.speaker.state.selectedDevice$.subscribe((deviceId) => {
|
|
12362
12470
|
const audioContext = this.getOrCreateAudioContext();
|
|
@@ -12378,13 +12486,51 @@ class DynascaleManager {
|
|
|
12378
12486
|
sinkIdSubscription?.unsubscribe();
|
|
12379
12487
|
volumeSubscription.unsubscribe();
|
|
12380
12488
|
updateMediaStreamSubscription.unsubscribe();
|
|
12489
|
+
clearTimeout(sourceChangeTimer);
|
|
12381
12490
|
audioElement.srcObject = null;
|
|
12382
12491
|
sourceNode?.disconnect();
|
|
12383
12492
|
gainNode?.disconnect();
|
|
12384
|
-
audioWatchdog
|
|
12493
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
12385
12494
|
audioWatchdog = undefined;
|
|
12386
12495
|
};
|
|
12387
12496
|
};
|
|
12497
|
+
/**
|
|
12498
|
+
* Creates a playback watchdog that is re-armed whenever a media
|
|
12499
|
+
* interruption (backgrounding, phone call, Siri) ends.
|
|
12500
|
+
*/
|
|
12501
|
+
createPlaybackWatchdog = (opts) => {
|
|
12502
|
+
const watchdog = new MediaPlaybackWatchdog(opts);
|
|
12503
|
+
this.playbackWatchdogs.add(watchdog);
|
|
12504
|
+
this.stopWatchingInterruptions ??= watchMediaInterruptions(this.rearmPlayback);
|
|
12505
|
+
return watchdog;
|
|
12506
|
+
};
|
|
12507
|
+
disposePlaybackWatchdog = (watchdog) => {
|
|
12508
|
+
if (!watchdog)
|
|
12509
|
+
return;
|
|
12510
|
+
watchdog.dispose();
|
|
12511
|
+
this.playbackWatchdogs.delete(watchdog);
|
|
12512
|
+
};
|
|
12513
|
+
rearmPlayback = () => {
|
|
12514
|
+
this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
|
|
12515
|
+
this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
|
|
12516
|
+
};
|
|
12517
|
+
/**
|
|
12518
|
+
* Traces the WebKit audio session state (initial value and changes) in
|
|
12519
|
+
* both playback modes, as it explains most iOS playback interruptions.
|
|
12520
|
+
*/
|
|
12521
|
+
traceAudioSessionState = () => {
|
|
12522
|
+
const audioSession = navigator.audioSession;
|
|
12523
|
+
if (!audioSession || this.stopTracingAudioSession)
|
|
12524
|
+
return;
|
|
12525
|
+
const trace = () => {
|
|
12526
|
+
this.tracer.trace('audioSession.state', audioSession.state);
|
|
12527
|
+
};
|
|
12528
|
+
trace();
|
|
12529
|
+
audioSession.addEventListener('statechange', trace);
|
|
12530
|
+
this.stopTracingAudioSession = () => {
|
|
12531
|
+
audioSession.removeEventListener('statechange', trace);
|
|
12532
|
+
};
|
|
12533
|
+
};
|
|
12388
12534
|
getOrCreateAudioContext = () => {
|
|
12389
12535
|
if (!this.useWebAudio)
|
|
12390
12536
|
return;
|
|
@@ -12407,7 +12553,6 @@ class DynascaleManager {
|
|
|
12407
12553
|
audioSession.type = 'play-and-record';
|
|
12408
12554
|
let isSessionInterrupted = false;
|
|
12409
12555
|
audioSession.addEventListener('statechange', () => {
|
|
12410
|
-
this.tracer.trace('audioSession.state', audioSession.state);
|
|
12411
12556
|
if (audioSession.state === 'interrupted') {
|
|
12412
12557
|
isSessionInterrupted = true;
|
|
12413
12558
|
}
|
|
@@ -12982,10 +13127,17 @@ const getDevices = (permission, kind, tracer) => {
|
|
|
12982
13127
|
/**
|
|
12983
13128
|
* Tells if the browser supports audio output change on 'audio' elements,
|
|
12984
13129
|
* see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
|
|
13130
|
+
*
|
|
13131
|
+
* WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
|
|
13132
|
+
* setSinkId requires a user gesture, a per-device output can run at a
|
|
13133
|
+
* different sample rate than the remote audio mixer (pitched/slowed audio),
|
|
13134
|
+
* and audio goes silent when the selected device disconnects.
|
|
12985
13135
|
*/
|
|
12986
13136
|
const checkIfAudioOutputChangeSupported = () => {
|
|
12987
13137
|
if (typeof document === 'undefined')
|
|
12988
13138
|
return false;
|
|
13139
|
+
if (isWebKit())
|
|
13140
|
+
return false;
|
|
12989
13141
|
const element = document.createElement('audio');
|
|
12990
13142
|
return 'setSinkId' in element;
|
|
12991
13143
|
};
|
|
@@ -13491,6 +13643,7 @@ class DeviceManager {
|
|
|
13491
13643
|
? forceStopOrOptions
|
|
13492
13644
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
13493
13645
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
13646
|
+
this.state.setError(undefined);
|
|
13494
13647
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
13495
13648
|
return;
|
|
13496
13649
|
}
|
|
@@ -13500,6 +13653,7 @@ class DeviceManager {
|
|
|
13500
13653
|
const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
|
|
13501
13654
|
await this.muteStream(stopTracks);
|
|
13502
13655
|
this.state.setStatus('disabled');
|
|
13656
|
+
this.state.setError(undefined);
|
|
13503
13657
|
}
|
|
13504
13658
|
finally {
|
|
13505
13659
|
if (!signal.aborted) {
|
|
@@ -13795,7 +13949,11 @@ class DeviceManager {
|
|
|
13795
13949
|
};
|
|
13796
13950
|
// the rootStream represents the stream coming from the actual device
|
|
13797
13951
|
// e.g. camera or microphone stream
|
|
13952
|
+
this.state.setError(undefined);
|
|
13798
13953
|
rootStreamPromise = this.getSelectedStream(constraints);
|
|
13954
|
+
rootStreamPromise.catch((error) => {
|
|
13955
|
+
this.state.setError(error);
|
|
13956
|
+
});
|
|
13799
13957
|
// we publish the last MediaStream of the chain
|
|
13800
13958
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
13801
13959
|
.then((inputStream) => {
|
|
@@ -13966,9 +14124,22 @@ class DeviceManager {
|
|
|
13966
14124
|
}
|
|
13967
14125
|
const devices = getCurrentValue(this.listDevices()) || [];
|
|
13968
14126
|
const currentDevice = this.findDevice(devices, selectedDevice) ??
|
|
13969
|
-
createSyntheticDevice(selectedDevice, deviceKind);
|
|
14127
|
+
createSyntheticDevice(selectedDevice, deviceKind, this.getActiveTrackLabel(selectedDevice));
|
|
13970
14128
|
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
13971
14129
|
}
|
|
14130
|
+
/**
|
|
14131
|
+
* Returns the label of the active track if it was captured from the given
|
|
14132
|
+
* device. Used as a fallback when the device list hasn't been enumerated yet,
|
|
14133
|
+
* so the persisted preference still carries the device label.
|
|
14134
|
+
*/
|
|
14135
|
+
getActiveTrackLabel = (deviceId) => {
|
|
14136
|
+
const stream = this.state.rootMediaStream;
|
|
14137
|
+
const tracks = this.trackType === TrackType.AUDIO
|
|
14138
|
+
? stream?.getAudioTracks()
|
|
14139
|
+
: stream?.getVideoTracks();
|
|
14140
|
+
const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
|
|
14141
|
+
return track?.label;
|
|
14142
|
+
};
|
|
13972
14143
|
async applyPersistedPreferences(enabledInCallType, forceDisabled = false) {
|
|
13973
14144
|
const deviceKey = this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
|
|
13974
14145
|
const preferences = readPreferences(this.devicePersistence.storageKey);
|
|
@@ -14020,6 +14191,7 @@ class DeviceManagerState {
|
|
|
14020
14191
|
rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
14021
14192
|
selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
|
|
14022
14193
|
defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
14194
|
+
errorSubject = new rxjs.BehaviorSubject(undefined);
|
|
14023
14195
|
/**
|
|
14024
14196
|
* @internal
|
|
14025
14197
|
*/
|
|
@@ -14054,6 +14226,13 @@ class DeviceManagerState {
|
|
|
14054
14226
|
* The default constraints for the device.
|
|
14055
14227
|
*/
|
|
14056
14228
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
14229
|
+
/**
|
|
14230
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
14231
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
14232
|
+
* For screen share, this includes the user dismissing the picker.
|
|
14233
|
+
* Cleared when the device is started again or disabled.
|
|
14234
|
+
*/
|
|
14235
|
+
error$ = this.errorSubject.asObservable().pipe(rxjs.distinctUntilChanged());
|
|
14057
14236
|
/**
|
|
14058
14237
|
* An observable that will emit `true` if browser/system permission
|
|
14059
14238
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -14121,6 +14300,19 @@ class DeviceManagerState {
|
|
|
14121
14300
|
get rootMediaStream() {
|
|
14122
14301
|
return getCurrentValue(this.rootMediaStream$);
|
|
14123
14302
|
}
|
|
14303
|
+
/**
|
|
14304
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
14305
|
+
*/
|
|
14306
|
+
get error() {
|
|
14307
|
+
return getCurrentValue(this.error$);
|
|
14308
|
+
}
|
|
14309
|
+
/**
|
|
14310
|
+
* @internal
|
|
14311
|
+
* @param error the error to set.
|
|
14312
|
+
*/
|
|
14313
|
+
setError(error) {
|
|
14314
|
+
setCurrentValue(this.errorSubject, error);
|
|
14315
|
+
}
|
|
14124
14316
|
/**
|
|
14125
14317
|
* @internal
|
|
14126
14318
|
* @param status
|
|
@@ -14702,9 +14894,11 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14702
14894
|
this.call.state.callingState$,
|
|
14703
14895
|
this.call.state.ownCapabilities$,
|
|
14704
14896
|
this.state.selectedDevice$,
|
|
14705
|
-
this.state.status$,
|
|
14706
14897
|
this.state.browserPermissionState$,
|
|
14707
|
-
|
|
14898
|
+
// only a trigger: queued handlers can run after the status changed
|
|
14899
|
+
// again, so the live status is read below
|
|
14900
|
+
this.state.status$,
|
|
14901
|
+
]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
14708
14902
|
try {
|
|
14709
14903
|
if (callingState === exports.CallingState.LEFT) {
|
|
14710
14904
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -14719,7 +14913,7 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14719
14913
|
return;
|
|
14720
14914
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
14721
14915
|
const hasPermission = await this.hasPermission(permissionState);
|
|
14722
|
-
if (hasPermission && status !== 'enabled') {
|
|
14916
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
14723
14917
|
this.setMutedRecordingPrepared(true);
|
|
14724
14918
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
14725
14919
|
}
|
|
@@ -15019,6 +15213,52 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15019
15213
|
await this.call.ensureMediaFactory();
|
|
15020
15214
|
return getAudioStream(constraints, this.call.tracer);
|
|
15021
15215
|
}
|
|
15216
|
+
/**
|
|
15217
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
15218
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
15219
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
15220
|
+
* profiles.
|
|
15221
|
+
*/
|
|
15222
|
+
async muteStream(stopTracks = true) {
|
|
15223
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
15224
|
+
const clone = track?.readyState === 'live' &&
|
|
15225
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
15226
|
+
this.canHandOffMic()
|
|
15227
|
+
? track.clone()
|
|
15228
|
+
: undefined;
|
|
15229
|
+
try {
|
|
15230
|
+
await super.muteStream(stopTracks);
|
|
15231
|
+
}
|
|
15232
|
+
catch (err) {
|
|
15233
|
+
clone?.stop();
|
|
15234
|
+
throw err;
|
|
15235
|
+
}
|
|
15236
|
+
if (!clone)
|
|
15237
|
+
return;
|
|
15238
|
+
// the mic, the call, or the selected device may have changed while
|
|
15239
|
+
// unpublishing
|
|
15240
|
+
const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
|
|
15241
|
+
if (clone.readyState !== 'live' ||
|
|
15242
|
+
!this.canHandOffMic() ||
|
|
15243
|
+
deviceId !== this.state.selectedDevice) {
|
|
15244
|
+
clone.stop();
|
|
15245
|
+
return;
|
|
15246
|
+
}
|
|
15247
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
15248
|
+
clone.stop();
|
|
15249
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
15250
|
+
});
|
|
15251
|
+
}
|
|
15252
|
+
/**
|
|
15253
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
15254
|
+
*/
|
|
15255
|
+
canHandOffMic = () => {
|
|
15256
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
15257
|
+
return (!isReactNative() &&
|
|
15258
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
15259
|
+
callingState === exports.CallingState.JOINED &&
|
|
15260
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO));
|
|
15261
|
+
};
|
|
15022
15262
|
doSetAudioBitrateProfile(profile) {
|
|
15023
15263
|
this.setDefaultConstraints({
|
|
15024
15264
|
...this.state.defaultConstraints,
|
|
@@ -15038,10 +15278,13 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15038
15278
|
}
|
|
15039
15279
|
}
|
|
15040
15280
|
}
|
|
15041
|
-
async startSpeakingWhileMutedDetection(deviceId) {
|
|
15281
|
+
async startSpeakingWhileMutedDetection(deviceId, track) {
|
|
15042
15282
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
15043
|
-
if (this.soundDetectorCleanup &&
|
|
15283
|
+
if (this.soundDetectorCleanup &&
|
|
15284
|
+
this.soundDetectorDeviceId === deviceId) {
|
|
15285
|
+
track?.stop();
|
|
15044
15286
|
return;
|
|
15287
|
+
}
|
|
15045
15288
|
await this.teardownSpeakingWhileMutedDetection();
|
|
15046
15289
|
if (isReactNative()) {
|
|
15047
15290
|
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
@@ -15057,11 +15300,14 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15057
15300
|
};
|
|
15058
15301
|
}
|
|
15059
15302
|
else {
|
|
15060
|
-
//
|
|
15061
|
-
|
|
15062
|
-
|
|
15063
|
-
|
|
15064
|
-
|
|
15303
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
15304
|
+
// connected to publisher
|
|
15305
|
+
const stream = track
|
|
15306
|
+
? new MediaStream([track])
|
|
15307
|
+
: await this.getStream({
|
|
15308
|
+
...this.state.defaultConstraints,
|
|
15309
|
+
deviceId: { exact: deviceId },
|
|
15310
|
+
});
|
|
15065
15311
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
15066
15312
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
15067
15313
|
});
|
|
@@ -15341,7 +15587,7 @@ class SpeakerManager {
|
|
|
15341
15587
|
}
|
|
15342
15588
|
async applyWeb() {
|
|
15343
15589
|
const { enabled, storageKey } = this.devicePersistence;
|
|
15344
|
-
if (!enabled)
|
|
15590
|
+
if (!enabled || !this.state.isDeviceSelectionSupported)
|
|
15345
15591
|
return;
|
|
15346
15592
|
const preferences = readPreferences(storageKey);
|
|
15347
15593
|
const preferenceList = toPreferenceList(preferences.speaker);
|
|
@@ -15617,6 +15863,14 @@ class Call {
|
|
|
15617
15863
|
networkAvailableTask;
|
|
15618
15864
|
// (10 attempts per rolling 120 s window).
|
|
15619
15865
|
rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
15866
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
15867
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
15868
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
15869
|
+
rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
15870
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
15871
|
+
// gathered no local ICE candidates.
|
|
15872
|
+
joinTasks = new Set();
|
|
15873
|
+
noLocalCandidatesCheckScheduled = false;
|
|
15620
15874
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
15621
15875
|
// failures where ICE never reached `connected`/`completed`.
|
|
15622
15876
|
maxIceFailuresWithoutConnect = 2;
|
|
@@ -16010,6 +16264,7 @@ class Call {
|
|
|
16010
16264
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
16011
16265
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
16012
16266
|
this.rejoinRateLimiter.reset();
|
|
16267
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
16013
16268
|
this.iceFailuresWithoutConnect = 0;
|
|
16014
16269
|
this.consecutiveNegotiationFailures = 0;
|
|
16015
16270
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -16405,6 +16660,16 @@ class Call {
|
|
|
16405
16660
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16406
16661
|
*/
|
|
16407
16662
|
doJoin = async (data) => {
|
|
16663
|
+
const task = this.performJoin(data);
|
|
16664
|
+
this.joinTasks.add(task);
|
|
16665
|
+
try {
|
|
16666
|
+
await task;
|
|
16667
|
+
}
|
|
16668
|
+
finally {
|
|
16669
|
+
this.joinTasks.delete(task);
|
|
16670
|
+
}
|
|
16671
|
+
};
|
|
16672
|
+
performJoin = async (data) => {
|
|
16408
16673
|
const connectStartTime = Date.now();
|
|
16409
16674
|
const callingState = this.state.callingState;
|
|
16410
16675
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -16762,6 +17027,9 @@ class Call {
|
|
|
16762
17027
|
// "ICE never connected" failure budget can be cleared.
|
|
16763
17028
|
this.iceFailuresWithoutConnect = 0;
|
|
16764
17029
|
},
|
|
17030
|
+
onNoLocalCandidates: () => {
|
|
17031
|
+
this.checkNoLocalCandidates();
|
|
17032
|
+
},
|
|
16765
17033
|
onPeerConnectionStateChange: (event) => {
|
|
16766
17034
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
16767
17035
|
},
|
|
@@ -16804,15 +17072,65 @@ class Call {
|
|
|
16804
17072
|
options: statsOptions,
|
|
16805
17073
|
subscriber: this.subscriber,
|
|
16806
17074
|
publisher: this.publisher,
|
|
16807
|
-
microphone: this.microphone,
|
|
16808
|
-
camera: this.camera,
|
|
16809
|
-
state: this.state,
|
|
16810
17075
|
tracer: this.tracer,
|
|
16811
17076
|
unifiedSessionId,
|
|
16812
17077
|
});
|
|
16813
17078
|
this.sfuStatsReporter.start();
|
|
16814
17079
|
}
|
|
16815
17080
|
};
|
|
17081
|
+
/**
|
|
17082
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
17083
|
+
* connections never connected and one of them completed ICE gathering
|
|
17084
|
+
* without a single local candidate, which means WebRTC networking is
|
|
17085
|
+
* blocked on this device.
|
|
17086
|
+
*
|
|
17087
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
17088
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
17089
|
+
* against the peer connections current at that time.
|
|
17090
|
+
*/
|
|
17091
|
+
checkNoLocalCandidates = () => {
|
|
17092
|
+
if (this.noLocalCandidatesCheckScheduled)
|
|
17093
|
+
return;
|
|
17094
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
17095
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
17096
|
+
const leaveGeneration = this.leaveGeneration;
|
|
17097
|
+
this.waitForJoinsAndReconnects()
|
|
17098
|
+
.then(() => {
|
|
17099
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17100
|
+
if (this.leaveGeneration !== leaveGeneration)
|
|
17101
|
+
return;
|
|
17102
|
+
this.checkNoLocalCandidates();
|
|
17103
|
+
})
|
|
17104
|
+
.catch((err) => {
|
|
17105
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17106
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
17107
|
+
});
|
|
17108
|
+
return;
|
|
17109
|
+
}
|
|
17110
|
+
if (this.state.callingState !== exports.CallingState.JOINED)
|
|
17111
|
+
return;
|
|
17112
|
+
// without network, gathering legitimately yields no candidates
|
|
17113
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false)
|
|
17114
|
+
return;
|
|
17115
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
17116
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
|
|
17117
|
+
return;
|
|
17118
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
|
|
17119
|
+
return;
|
|
17120
|
+
this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
|
|
17121
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17122
|
+
});
|
|
17123
|
+
};
|
|
17124
|
+
/**
|
|
17125
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
17126
|
+
*/
|
|
17127
|
+
waitForJoinsAndReconnects = async () => {
|
|
17128
|
+
while (this.joinTasks.size > 0 ||
|
|
17129
|
+
hasPending(this.reconnectConcurrencyTag)) {
|
|
17130
|
+
await Promise.allSettled(this.joinTasks);
|
|
17131
|
+
await settled(this.reconnectConcurrencyTag);
|
|
17132
|
+
}
|
|
17133
|
+
};
|
|
16816
17134
|
/**
|
|
16817
17135
|
* Retrieves credentials for joining the call.
|
|
16818
17136
|
*
|
|
@@ -17011,6 +17329,14 @@ class Call {
|
|
|
17011
17329
|
return;
|
|
17012
17330
|
}
|
|
17013
17331
|
}
|
|
17332
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
17333
|
+
// toward the long-window backstop.
|
|
17334
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
17335
|
+
this.rejoinLongWindowRateLimiter &&
|
|
17336
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()) {
|
|
17337
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
17338
|
+
return;
|
|
17339
|
+
}
|
|
17014
17340
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
17015
17341
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
17016
17342
|
this.reconnectAttempts++;
|
|
@@ -17229,7 +17555,13 @@ class Call {
|
|
|
17229
17555
|
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
17230
17556
|
});
|
|
17231
17557
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
17232
|
-
|
|
17558
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
17559
|
+
// still report errors about its own (replaced) session, and none may
|
|
17560
|
+
// while `leave()` tears the call down.
|
|
17561
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
17562
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
17563
|
+
if (!this.sfuClient)
|
|
17564
|
+
return;
|
|
17233
17565
|
const { reconnectStrategy: strategy, error } = e;
|
|
17234
17566
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
17235
17567
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -17252,7 +17584,16 @@ class Call {
|
|
|
17252
17584
|
});
|
|
17253
17585
|
}
|
|
17254
17586
|
else {
|
|
17255
|
-
|
|
17587
|
+
// A media transport failure on a session whose peer connections
|
|
17588
|
+
// never connected is an "ICE never connected" failure, and must
|
|
17589
|
+
// count toward the unsupported-network budget.
|
|
17590
|
+
const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
17591
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
17592
|
+
!this.subscriber?.hasIceEverConnected();
|
|
17593
|
+
const reason = neverConnected
|
|
17594
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
17595
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
17596
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
17256
17597
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17257
17598
|
});
|
|
17258
17599
|
}
|
|
@@ -18316,9 +18657,11 @@ class Call {
|
|
|
18316
18657
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
18317
18658
|
*
|
|
18318
18659
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
18319
|
-
* Both arguments are clamped to a minimum of 1.
|
|
18660
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
18661
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
18320
18662
|
*/
|
|
18321
18663
|
setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
18664
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
18322
18665
|
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
18323
18666
|
};
|
|
18324
18667
|
/**
|
|
@@ -19656,11 +19999,11 @@ class StreamClient {
|
|
|
19656
19999
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19657
20000
|
};
|
|
19658
20001
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19659
|
-
"1.
|
|
20002
|
+
"1.62.0";
|
|
19660
20003
|
getUserAgent = () => {
|
|
19661
20004
|
if (!this.cachedUserAgent) {
|
|
19662
20005
|
const { clientAppIdentifier = {} } = this.options;
|
|
19663
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
20006
|
+
const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
|
|
19664
20007
|
this.cachedUserAgent = [
|
|
19665
20008
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19666
20009
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -20600,6 +20943,10 @@ class StreamVideoClient {
|
|
|
20600
20943
|
const call = this.writeableStateStore.findCall(c.call.type, c.call.id);
|
|
20601
20944
|
if (call) {
|
|
20602
20945
|
call.updateFromCallStateResponse(c);
|
|
20946
|
+
// ring events missed while the WS was down aren't replayed
|
|
20947
|
+
reconcileRingState(call, 'ring-poll-api').catch((err) => {
|
|
20948
|
+
call.logger.error('Failed to reconcile the ring state after rewatch', err);
|
|
20949
|
+
});
|
|
20603
20950
|
}
|
|
20604
20951
|
}
|
|
20605
20952
|
return;
|