@stream-io/video-client 1.61.2 → 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 +12 -0
- package/dist/index.browser.es.js +247 -29
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +247 -29
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +247 -29
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- 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/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/Call.ts +110 -6
- package/src/devices/DeviceManager.ts +6 -0
- 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__/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 +1 -0
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/helpers/DynascaleManager.ts +2 -2
- package/src/helpers/__tests__/DynascaleManager.test.ts +6 -4
- 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/dist/src/Call.d.ts
CHANGED
|
@@ -124,6 +124,9 @@ export declare class Call {
|
|
|
124
124
|
private lastOfflineTimestamp;
|
|
125
125
|
private networkAvailableTask;
|
|
126
126
|
private readonly rejoinRateLimiter;
|
|
127
|
+
private rejoinLongWindowRateLimiter?;
|
|
128
|
+
private readonly joinTasks;
|
|
129
|
+
private noLocalCandidatesCheckScheduled;
|
|
127
130
|
private maxIceFailuresWithoutConnect;
|
|
128
131
|
private iceFailuresWithoutConnect;
|
|
129
132
|
private maxConsecutiveNegotiationFailures;
|
|
@@ -321,6 +324,7 @@ export declare class Call {
|
|
|
321
324
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
322
325
|
*/
|
|
323
326
|
private doJoin;
|
|
327
|
+
private performJoin;
|
|
324
328
|
/**
|
|
325
329
|
* Prepares Reconnect Details object.
|
|
326
330
|
* @internal
|
|
@@ -354,6 +358,21 @@ export declare class Call {
|
|
|
354
358
|
* @internal
|
|
355
359
|
*/
|
|
356
360
|
private initPublisherAndSubscriber;
|
|
361
|
+
/**
|
|
362
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
363
|
+
* connections never connected and one of them completed ICE gathering
|
|
364
|
+
* without a single local candidate, which means WebRTC networking is
|
|
365
|
+
* blocked on this device.
|
|
366
|
+
*
|
|
367
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
368
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
369
|
+
* against the peer connections current at that time.
|
|
370
|
+
*/
|
|
371
|
+
private checkNoLocalCandidates;
|
|
372
|
+
/**
|
|
373
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
374
|
+
*/
|
|
375
|
+
private waitForJoinsAndReconnects;
|
|
357
376
|
/**
|
|
358
377
|
* Retrieves credentials for joining the call.
|
|
359
378
|
*
|
|
@@ -996,7 +1015,8 @@ export declare class Call {
|
|
|
996
1015
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
997
1016
|
*
|
|
998
1017
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
999
|
-
* Both arguments are clamped to a minimum of 1.
|
|
1018
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
1019
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
1000
1020
|
*/
|
|
1001
1021
|
setRejoinAttemptLimit: (maxAttempts: number, windowSeconds: number) => void;
|
|
1002
1022
|
/**
|
|
@@ -9,6 +9,7 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
9
9
|
protected rootMediaStreamSubject: BehaviorSubject<MediaStream | undefined>;
|
|
10
10
|
protected selectedDeviceSubject: BehaviorSubject<string | undefined>;
|
|
11
11
|
protected defaultConstraintsSubject: BehaviorSubject<C | undefined>;
|
|
12
|
+
protected errorSubject: BehaviorSubject<Error | undefined>;
|
|
12
13
|
/**
|
|
13
14
|
* @internal
|
|
14
15
|
*/
|
|
@@ -39,6 +40,13 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
39
40
|
* The default constraints for the device.
|
|
40
41
|
*/
|
|
41
42
|
defaultConstraints$: Observable<C | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
45
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
46
|
+
* For screen share, this includes the user dismissing the picker.
|
|
47
|
+
* Cleared when the device is started again or disabled.
|
|
48
|
+
*/
|
|
49
|
+
error$: Observable<Error | undefined>;
|
|
42
50
|
/**
|
|
43
51
|
* An observable that will emit `true` if browser/system permission
|
|
44
52
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -85,6 +93,15 @@ export declare abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
85
93
|
* same as `mediaStream`.
|
|
86
94
|
*/
|
|
87
95
|
get rootMediaStream(): MediaStream | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
98
|
+
*/
|
|
99
|
+
get error(): Error | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* @internal
|
|
102
|
+
* @param error the error to set.
|
|
103
|
+
*/
|
|
104
|
+
setError(error: Error | undefined): void;
|
|
88
105
|
/**
|
|
89
106
|
* @internal
|
|
90
107
|
* @param status
|
|
@@ -74,6 +74,17 @@ export declare class MicrophoneManager extends AudioDeviceManager<MicrophoneMana
|
|
|
74
74
|
apply(settings: AudioSettingsResponse, publish: boolean, forceDisabled?: boolean): Promise<void>;
|
|
75
75
|
protected getDevices(): Observable<MediaDeviceInfo[]>;
|
|
76
76
|
protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
|
|
77
|
+
/**
|
|
78
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
79
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
80
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
81
|
+
* profiles.
|
|
82
|
+
*/
|
|
83
|
+
protected muteStream(stopTracks?: boolean): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
86
|
+
*/
|
|
87
|
+
private canHandOffMic;
|
|
77
88
|
protected doSetAudioBitrateProfile(profile: AudioBitrateProfile): void;
|
|
78
89
|
private startSpeakingWhileMutedDetection;
|
|
79
90
|
private stopSpeakingWhileMutedDetection;
|
|
@@ -3,6 +3,11 @@ import { Tracer } from '../stats';
|
|
|
3
3
|
/**
|
|
4
4
|
* Tells if the browser supports audio output change on 'audio' elements,
|
|
5
5
|
* see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
|
|
6
|
+
*
|
|
7
|
+
* WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
|
|
8
|
+
* setSinkId requires a user gesture, a per-device output can run at a
|
|
9
|
+
* different sample rate than the remote audio mixer (pitched/slowed audio),
|
|
10
|
+
* and audio goes silent when the selected device disconnects.
|
|
6
11
|
*/
|
|
7
12
|
export declare const checkIfAudioOutputChangeSupported: () => boolean;
|
|
8
13
|
/**
|
|
@@ -24,10 +24,12 @@ export declare abstract class BasePeerConnection {
|
|
|
24
24
|
protected sfuClient: StreamSfuClient;
|
|
25
25
|
protected onReconnectionNeeded?: OnReconnectionNeeded;
|
|
26
26
|
private onIceConnected?;
|
|
27
|
+
private onNoLocalCandidates?;
|
|
27
28
|
private onPeerConnectionStateChange?;
|
|
28
29
|
protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
29
30
|
private readonly iceRestartDelay;
|
|
30
31
|
private iceHasEverConnected;
|
|
32
|
+
private localCandidatesGathered;
|
|
31
33
|
private iceRestartTimeout?;
|
|
32
34
|
private preConnectStuckTimeout?;
|
|
33
35
|
protected isIceRestarting: boolean;
|
|
@@ -41,7 +43,7 @@ export declare abstract class BasePeerConnection {
|
|
|
41
43
|
/**
|
|
42
44
|
* Constructs a new `BasePeerConnection` instance.
|
|
43
45
|
*/
|
|
44
|
-
protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay, statsTimestampDriftThresholdMs, }: BasePeerConnectionOpts);
|
|
46
|
+
protected constructor(peerType: PeerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay, statsTimestampDriftThresholdMs, }: BasePeerConnectionOpts);
|
|
45
47
|
private createPeerConnection;
|
|
46
48
|
/**
|
|
47
49
|
* Disposes the `RTCPeerConnection` instance.
|
|
@@ -90,6 +92,16 @@ export declare abstract class BasePeerConnection {
|
|
|
90
92
|
* Maps the given track ID to the corresponding track type.
|
|
91
93
|
*/
|
|
92
94
|
getTrackType: (trackId: string) => TrackType | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
97
|
+
* once during the lifetime of this peer connection.
|
|
98
|
+
*/
|
|
99
|
+
hasIceEverConnected: () => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
102
|
+
* producing a single local candidate.
|
|
103
|
+
*/
|
|
104
|
+
hasNoLocalCandidates: () => boolean;
|
|
93
105
|
/**
|
|
94
106
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
95
107
|
* It checks the ICE connection state and the peer connection state.
|
package/dist/src/rtc/types.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ export type OnReconnectionNeeded = (kind: WebsocketReconnectStrategy, reason: Re
|
|
|
40
40
|
* recovered, not merely when the SFU join handshake succeeded.
|
|
41
41
|
*/
|
|
42
42
|
export type OnIceConnected = (peerType: PeerType) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Fires when an ICE gathering round completes without producing a single
|
|
45
|
+
* local candidate on a peer connection that never connected. Used by `Call`
|
|
46
|
+
* to detect devices whose WebRTC networking is blocked.
|
|
47
|
+
*/
|
|
48
|
+
export type OnNoLocalCandidates = (peerType: PeerType) => void;
|
|
43
49
|
/**
|
|
44
50
|
* Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
|
|
45
51
|
* consumers (e.g. `ClientEventReporter`). Fired on every transition of
|
|
@@ -75,6 +81,7 @@ export type BasePeerConnectionOpts = {
|
|
|
75
81
|
dispatcher: Dispatcher;
|
|
76
82
|
onReconnectionNeeded?: OnReconnectionNeeded;
|
|
77
83
|
onIceConnected?: OnIceConnected;
|
|
84
|
+
onNoLocalCandidates?: OnNoLocalCandidates;
|
|
78
85
|
onPeerConnectionStateChange?: OnPeerConnectionStateChange;
|
|
79
86
|
onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
80
87
|
tag: string;
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -183,6 +183,7 @@ import {
|
|
|
183
183
|
import { normalize } from './devices/devicePersistence';
|
|
184
184
|
import {
|
|
185
185
|
hasPending,
|
|
186
|
+
settled,
|
|
186
187
|
singleFlight,
|
|
187
188
|
withoutConcurrency,
|
|
188
189
|
} from './helpers/concurrency';
|
|
@@ -325,6 +326,15 @@ export class Call {
|
|
|
325
326
|
|
|
326
327
|
// (10 attempts per rolling 120 s window).
|
|
327
328
|
private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
329
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
330
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
331
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
332
|
+
private rejoinLongWindowRateLimiter?: SlidingWindowRateLimiter =
|
|
333
|
+
new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
334
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
335
|
+
// gathered no local ICE candidates.
|
|
336
|
+
private readonly joinTasks = new Set<Promise<void>>();
|
|
337
|
+
private noLocalCandidatesCheckScheduled = false;
|
|
328
338
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
329
339
|
// failures where ICE never reached `connected`/`completed`.
|
|
330
340
|
private maxIceFailuresWithoutConnect = 2;
|
|
@@ -810,6 +820,7 @@ export class Call {
|
|
|
810
820
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
811
821
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
812
822
|
this.rejoinRateLimiter.reset();
|
|
823
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
813
824
|
this.iceFailuresWithoutConnect = 0;
|
|
814
825
|
this.consecutiveNegotiationFailures = 0;
|
|
815
826
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -1313,6 +1324,16 @@ export class Call {
|
|
|
1313
1324
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
1314
1325
|
*/
|
|
1315
1326
|
private doJoin = async (data?: JoinCallData): Promise<void> => {
|
|
1327
|
+
const task = this.performJoin(data);
|
|
1328
|
+
this.joinTasks.add(task);
|
|
1329
|
+
try {
|
|
1330
|
+
await task;
|
|
1331
|
+
} finally {
|
|
1332
|
+
this.joinTasks.delete(task);
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
private performJoin = async (data?: JoinCallData): Promise<void> => {
|
|
1316
1337
|
const connectStartTime = Date.now();
|
|
1317
1338
|
const callingState = this.state.callingState;
|
|
1318
1339
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -1761,6 +1782,9 @@ export class Call {
|
|
|
1761
1782
|
// "ICE never connected" failure budget can be cleared.
|
|
1762
1783
|
this.iceFailuresWithoutConnect = 0;
|
|
1763
1784
|
},
|
|
1785
|
+
onNoLocalCandidates: () => {
|
|
1786
|
+
this.checkNoLocalCandidates();
|
|
1787
|
+
},
|
|
1764
1788
|
onPeerConnectionStateChange: (event) => {
|
|
1765
1789
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
1766
1790
|
},
|
|
@@ -1819,6 +1843,62 @@ export class Call {
|
|
|
1819
1843
|
}
|
|
1820
1844
|
};
|
|
1821
1845
|
|
|
1846
|
+
/**
|
|
1847
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
1848
|
+
* connections never connected and one of them completed ICE gathering
|
|
1849
|
+
* without a single local candidate, which means WebRTC networking is
|
|
1850
|
+
* blocked on this device.
|
|
1851
|
+
*
|
|
1852
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
1853
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
1854
|
+
* against the peer connections current at that time.
|
|
1855
|
+
*/
|
|
1856
|
+
private checkNoLocalCandidates = () => {
|
|
1857
|
+
if (this.noLocalCandidatesCheckScheduled) return;
|
|
1858
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
1859
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
1860
|
+
const leaveGeneration = this.leaveGeneration;
|
|
1861
|
+
this.waitForJoinsAndReconnects()
|
|
1862
|
+
.then(() => {
|
|
1863
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
1864
|
+
if (this.leaveGeneration !== leaveGeneration) return;
|
|
1865
|
+
this.checkNoLocalCandidates();
|
|
1866
|
+
})
|
|
1867
|
+
.catch((err) => {
|
|
1868
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
1869
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
1870
|
+
});
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (this.state.callingState !== CallingState.JOINED) return;
|
|
1875
|
+
// without network, gathering legitimately yields no candidates
|
|
1876
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
|
|
1877
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
1878
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected())) return;
|
|
1879
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates())) return;
|
|
1880
|
+
|
|
1881
|
+
this.reconnect(
|
|
1882
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
1883
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
1884
|
+
).catch((err) => {
|
|
1885
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
1886
|
+
});
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
1891
|
+
*/
|
|
1892
|
+
private waitForJoinsAndReconnects = async () => {
|
|
1893
|
+
while (
|
|
1894
|
+
this.joinTasks.size > 0 ||
|
|
1895
|
+
hasPending(this.reconnectConcurrencyTag)
|
|
1896
|
+
) {
|
|
1897
|
+
await Promise.allSettled(this.joinTasks);
|
|
1898
|
+
await settled(this.reconnectConcurrencyTag);
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1822
1902
|
/**
|
|
1823
1903
|
* Retrieves credentials for joining the call.
|
|
1824
1904
|
*
|
|
@@ -2060,6 +2140,16 @@ export class Call {
|
|
|
2060
2140
|
return;
|
|
2061
2141
|
}
|
|
2062
2142
|
}
|
|
2143
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
2144
|
+
// toward the long-window backstop.
|
|
2145
|
+
if (
|
|
2146
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
2147
|
+
this.rejoinLongWindowRateLimiter &&
|
|
2148
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()
|
|
2149
|
+
) {
|
|
2150
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2063
2153
|
|
|
2064
2154
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
2065
2155
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
@@ -2340,7 +2430,12 @@ export class Call {
|
|
|
2340
2430
|
});
|
|
2341
2431
|
|
|
2342
2432
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
2343
|
-
|
|
2433
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
2434
|
+
// still report errors about its own (replaced) session, and none may
|
|
2435
|
+
// while `leave()` tears the call down.
|
|
2436
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
2437
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
2438
|
+
if (!this.sfuClient) return;
|
|
2344
2439
|
const { reconnectStrategy: strategy, error } = e;
|
|
2345
2440
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
2346
2441
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -2360,10 +2455,17 @@ export class Call {
|
|
|
2360
2455
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
2361
2456
|
});
|
|
2362
2457
|
} else {
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2458
|
+
// A media transport failure on a session whose peer connections
|
|
2459
|
+
// never connected is an "ICE never connected" failure, and must
|
|
2460
|
+
// count toward the unsupported-network budget.
|
|
2461
|
+
const neverConnected =
|
|
2462
|
+
error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
2463
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
2464
|
+
!this.subscriber?.hasIceEverConnected();
|
|
2465
|
+
const reason = neverConnected
|
|
2466
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
2467
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
2468
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
2367
2469
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
2368
2470
|
});
|
|
2369
2471
|
}
|
|
@@ -3801,9 +3903,11 @@ export class Call {
|
|
|
3801
3903
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
3802
3904
|
*
|
|
3803
3905
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
3804
|
-
* Both arguments are clamped to a minimum of 1.
|
|
3906
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
3907
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
3805
3908
|
*/
|
|
3806
3909
|
setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
|
|
3910
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
3807
3911
|
this.rejoinRateLimiter.setLimits(
|
|
3808
3912
|
Math.max(1, maxAttempts),
|
|
3809
3913
|
Math.max(1, windowSeconds) * 1000,
|
|
@@ -298,6 +298,7 @@ export abstract class DeviceManager<
|
|
|
298
298
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
299
299
|
|
|
300
300
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
301
|
+
this.state.setError(undefined);
|
|
301
302
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
302
303
|
return;
|
|
303
304
|
}
|
|
@@ -310,6 +311,7 @@ export abstract class DeviceManager<
|
|
|
310
311
|
forceStop || this.state.disableMode === 'stop-tracks';
|
|
311
312
|
await this.muteStream(stopTracks);
|
|
312
313
|
this.state.setStatus('disabled');
|
|
314
|
+
this.state.setError(undefined);
|
|
313
315
|
} finally {
|
|
314
316
|
if (!signal.aborted) {
|
|
315
317
|
this.state.setPendingStatus(this.state.status);
|
|
@@ -638,7 +640,11 @@ export abstract class DeviceManager<
|
|
|
638
640
|
|
|
639
641
|
// the rootStream represents the stream coming from the actual device
|
|
640
642
|
// e.g. camera or microphone stream
|
|
643
|
+
this.state.setError(undefined);
|
|
641
644
|
rootStreamPromise = this.getSelectedStream(constraints as C);
|
|
645
|
+
rootStreamPromise.catch((error) => {
|
|
646
|
+
this.state.setError(error);
|
|
647
|
+
});
|
|
642
648
|
// we publish the last MediaStream of the chain
|
|
643
649
|
stream = await this.filters.reduce(
|
|
644
650
|
(parent, entry) =>
|
|
@@ -28,6 +28,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
28
28
|
protected defaultConstraintsSubject = new BehaviorSubject<C | undefined>(
|
|
29
29
|
undefined,
|
|
30
30
|
);
|
|
31
|
+
protected errorSubject = new BehaviorSubject<Error | undefined>(undefined);
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* @internal
|
|
@@ -70,6 +71,14 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
70
71
|
*/
|
|
71
72
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
76
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
77
|
+
* For screen share, this includes the user dismissing the picker.
|
|
78
|
+
* Cleared when the device is started again or disabled.
|
|
79
|
+
*/
|
|
80
|
+
error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
|
|
81
|
+
|
|
73
82
|
/**
|
|
74
83
|
* An observable that will emit `true` if browser/system permission
|
|
75
84
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -152,6 +161,21 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
152
161
|
return RxUtils.getCurrentValue(this.rootMediaStream$);
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
166
|
+
*/
|
|
167
|
+
get error() {
|
|
168
|
+
return RxUtils.getCurrentValue(this.error$);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @internal
|
|
173
|
+
* @param error the error to set.
|
|
174
|
+
*/
|
|
175
|
+
setError(error: Error | undefined) {
|
|
176
|
+
RxUtils.setCurrentValue(this.errorSubject, error);
|
|
177
|
+
}
|
|
178
|
+
|
|
155
179
|
/**
|
|
156
180
|
* @internal
|
|
157
181
|
* @param status
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { type BrowserPermissionState } from './BrowserPermission';
|
|
9
9
|
import { MicrophoneManagerState } from './MicrophoneManagerState';
|
|
10
10
|
import { TrackDisableMode } from './DeviceManagerState';
|
|
11
|
-
import { getAudioDevices, getAudioStream } from './devices';
|
|
11
|
+
import { getAudioDevices, getAudioStream, resolveDeviceId } from './devices';
|
|
12
12
|
import { AudioBitrateProfile, TrackType } from '../gen/video/sfu/models/models';
|
|
13
13
|
import { createSoundDetector } from '../helpers/sound-detector';
|
|
14
14
|
import { createNoAudioDetector } from '../helpers/no-audio-detector';
|
|
@@ -64,16 +64,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
64
64
|
this.call.state.callingState$,
|
|
65
65
|
this.call.state.ownCapabilities$,
|
|
66
66
|
this.state.selectedDevice$,
|
|
67
|
-
this.state.status$,
|
|
68
67
|
this.state.browserPermissionState$,
|
|
68
|
+
// only a trigger: queued handlers can run after the status changed
|
|
69
|
+
// again, so the live status is read below
|
|
70
|
+
this.state.status$,
|
|
69
71
|
]),
|
|
70
|
-
async ([
|
|
71
|
-
callingState,
|
|
72
|
-
ownCapabilities,
|
|
73
|
-
deviceId,
|
|
74
|
-
status,
|
|
75
|
-
permissionState,
|
|
76
|
-
]) => {
|
|
72
|
+
async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
77
73
|
try {
|
|
78
74
|
if (callingState === CallingState.LEFT) {
|
|
79
75
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -87,7 +83,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
87
83
|
|
|
88
84
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
89
85
|
const hasPermission = await this.hasPermission(permissionState);
|
|
90
|
-
if (hasPermission && status !== 'enabled') {
|
|
86
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
91
87
|
this.setMutedRecordingPrepared(true);
|
|
92
88
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
93
89
|
} else {
|
|
@@ -450,6 +446,61 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
450
446
|
return getAudioStream(constraints, this.call.tracer);
|
|
451
447
|
}
|
|
452
448
|
|
|
449
|
+
/**
|
|
450
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
451
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
452
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
453
|
+
* profiles.
|
|
454
|
+
*/
|
|
455
|
+
protected override async muteStream(stopTracks: boolean = true) {
|
|
456
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
457
|
+
const clone =
|
|
458
|
+
track?.readyState === 'live' &&
|
|
459
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
460
|
+
this.canHandOffMic()
|
|
461
|
+
? track.clone()
|
|
462
|
+
: undefined;
|
|
463
|
+
try {
|
|
464
|
+
await super.muteStream(stopTracks);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
clone?.stop();
|
|
467
|
+
throw err;
|
|
468
|
+
}
|
|
469
|
+
if (!clone) return;
|
|
470
|
+
|
|
471
|
+
// the mic, the call, or the selected device may have changed while
|
|
472
|
+
// unpublishing
|
|
473
|
+
const deviceId = resolveDeviceId(
|
|
474
|
+
clone.getSettings().deviceId,
|
|
475
|
+
'audioinput',
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
clone.readyState !== 'live' ||
|
|
479
|
+
!this.canHandOffMic() ||
|
|
480
|
+
deviceId !== this.state.selectedDevice
|
|
481
|
+
) {
|
|
482
|
+
clone.stop();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
486
|
+
clone.stop();
|
|
487
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
493
|
+
*/
|
|
494
|
+
private canHandOffMic = () => {
|
|
495
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
496
|
+
return (
|
|
497
|
+
!isReactNative() &&
|
|
498
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
499
|
+
callingState === CallingState.JOINED &&
|
|
500
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO)
|
|
501
|
+
);
|
|
502
|
+
};
|
|
503
|
+
|
|
453
504
|
protected override doSetAudioBitrateProfile(profile: AudioBitrateProfile) {
|
|
454
505
|
this.setDefaultConstraints({
|
|
455
506
|
...this.state.defaultConstraints,
|
|
@@ -474,10 +525,18 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
474
525
|
}
|
|
475
526
|
}
|
|
476
527
|
|
|
477
|
-
private async startSpeakingWhileMutedDetection(
|
|
528
|
+
private async startSpeakingWhileMutedDetection(
|
|
529
|
+
deviceId?: string,
|
|
530
|
+
track?: MediaStreamTrack,
|
|
531
|
+
) {
|
|
478
532
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
479
|
-
if (
|
|
533
|
+
if (
|
|
534
|
+
this.soundDetectorCleanup &&
|
|
535
|
+
this.soundDetectorDeviceId === deviceId
|
|
536
|
+
) {
|
|
537
|
+
track?.stop();
|
|
480
538
|
return;
|
|
539
|
+
}
|
|
481
540
|
|
|
482
541
|
await this.teardownSpeakingWhileMutedDetection();
|
|
483
542
|
|
|
@@ -497,11 +556,14 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
497
556
|
unsubscribe();
|
|
498
557
|
};
|
|
499
558
|
} else {
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
559
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
560
|
+
// connected to publisher
|
|
561
|
+
const stream = track
|
|
562
|
+
? new MediaStream([track])
|
|
563
|
+
: await this.getStream({
|
|
564
|
+
...this.state.defaultConstraints,
|
|
565
|
+
deviceId: { exact: deviceId },
|
|
566
|
+
});
|
|
505
567
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
506
568
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
507
569
|
});
|
|
@@ -49,7 +49,7 @@ export class SpeakerManager {
|
|
|
49
49
|
|
|
50
50
|
private async applyWeb() {
|
|
51
51
|
const { enabled, storageKey } = this.devicePersistence;
|
|
52
|
-
if (!enabled) return;
|
|
52
|
+
if (!enabled || !this.state.isDeviceSelectionSupported) return;
|
|
53
53
|
|
|
54
54
|
const preferences = readPreferences(storageKey);
|
|
55
55
|
const preferenceList = toPreferenceList(preferences.speaker);
|
|
@@ -22,6 +22,7 @@ import { firstValueFrom, of } from 'rxjs';
|
|
|
22
22
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
23
23
|
import { PermissionsContext } from '../../permissions';
|
|
24
24
|
import { defaultDeviceId, readPreferences } from '../devicePersistence';
|
|
25
|
+
import { promiseWithResolvers } from '../../helpers/promise';
|
|
25
26
|
|
|
26
27
|
vi.mock('../../reporting/ClientEventReporter', () => ({
|
|
27
28
|
ClientEventReporter: vi.fn(function () {
|
|
@@ -172,6 +173,34 @@ describe('Device Manager', () => {
|
|
|
172
173
|
expect(manager.state.status).toBe('disabled');
|
|
173
174
|
});
|
|
174
175
|
|
|
176
|
+
it('clears a startup error when disabling an already disabled device', async () => {
|
|
177
|
+
await manager.disable();
|
|
178
|
+
const error = new Error('Camera failed to start');
|
|
179
|
+
manager.getStream.mockRejectedValueOnce(error);
|
|
180
|
+
|
|
181
|
+
await expect(manager.enable()).rejects.toThrow(error);
|
|
182
|
+
expect(manager.state.status).toBe('disabled');
|
|
183
|
+
expect(manager.state.error).toBe(error);
|
|
184
|
+
|
|
185
|
+
await manager.disable();
|
|
186
|
+
|
|
187
|
+
expect(manager.state.error).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('clears a pending startup error when the queued disable completes', async () => {
|
|
191
|
+
const { promise, reject } = promiseWithResolvers<MediaStream>();
|
|
192
|
+
const error = new Error('Camera failed to start');
|
|
193
|
+
manager.getStream.mockReturnValueOnce(promise);
|
|
194
|
+
|
|
195
|
+
const enabling = expect(manager.enable()).rejects.toThrow(error);
|
|
196
|
+
const disabling = manager.disable();
|
|
197
|
+
reject(error);
|
|
198
|
+
await Promise.all([enabling, disabling]);
|
|
199
|
+
|
|
200
|
+
expect(manager.state.status).toBe('disabled');
|
|
201
|
+
expect(manager.state.error).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
175
204
|
it('toggle device', async () => {
|
|
176
205
|
vi.spyOn(manager, 'disable');
|
|
177
206
|
vi.spyOn(manager, 'enable');
|