@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.
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.61.2",
3
+ "version": "1.62.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
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
- const unregisterOnError = this.on('error', (e) => {
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
- this.reconnect(
2364
- strategy,
2365
- error?.message || ReconnectReason.SFU_ERROR,
2366
- ).catch((err) => {
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(deviceId?: string) {
528
+ private async startSpeakingWhileMutedDetection(
529
+ deviceId?: string,
530
+ track?: MediaStreamTrack,
531
+ ) {
478
532
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
479
- if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
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
- // Need to start a new stream that's not connected to publisher
501
- const stream = await this.getStream({
502
- ...this.state.defaultConstraints,
503
- deviceId: { exact: deviceId },
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');