@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +525 -178
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +525 -178
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +525 -178
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +21 -1
  9. package/dist/src/coordinator/connection/types.d.ts +1 -1
  10. package/dist/src/devices/DeviceManager.d.ts +6 -0
  11. package/dist/src/devices/DeviceManagerState.d.ts +17 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +11 -0
  13. package/dist/src/devices/devices.d.ts +5 -0
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  15. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  16. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  17. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  18. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  20. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  21. package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
  22. package/dist/src/rtc/types.d.ts +7 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  25. package/dist/src/stats/rtc/types.d.ts +0 -11
  26. package/package.json +1 -1
  27. package/src/Call.ts +110 -9
  28. package/src/StreamVideoClient.ts +8 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  30. package/src/coordinator/connection/types.ts +1 -1
  31. package/src/devices/DeviceManager.ts +26 -1
  32. package/src/devices/DeviceManagerState.ts +24 -0
  33. package/src/devices/MicrophoneManager.ts +79 -17
  34. package/src/devices/SpeakerManager.ts +1 -1
  35. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +29 -0
  37. package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
  38. package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
  39. package/src/devices/__tests__/devices.test.ts +10 -14
  40. package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
  41. package/src/devices/__tests__/mocks.ts +1 -0
  42. package/src/devices/devices.ts +7 -1
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  44. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  45. package/src/helpers/DynascaleManager.ts +86 -11
  46. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  47. package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
  48. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  49. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  50. package/src/helpers/mediaInterruptions.ts +37 -0
  51. package/src/reporting/ClientEventReporter.ts +1 -1
  52. package/src/ringing/RingStatePoller.ts +1 -1
  53. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  54. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  55. package/src/ringing/reconcileRingState.ts +18 -10
  56. package/src/rtc/BasePeerConnection.ts +47 -9
  57. package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
  58. package/src/rtc/types.ts +8 -0
  59. package/src/stats/SfuStatsReporter.ts +19 -93
  60. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  61. package/src/stats/rtc/StatsTracer.ts +6 -6
  62. package/src/stats/rtc/types.ts +0 -13
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
  },
@@ -1812,9 +1836,6 @@ export class Call {
1812
1836
  options: statsOptions,
1813
1837
  subscriber: this.subscriber,
1814
1838
  publisher: this.publisher,
1815
- microphone: this.microphone,
1816
- camera: this.camera,
1817
- state: this.state,
1818
1839
  tracer: this.tracer,
1819
1840
  unifiedSessionId,
1820
1841
  });
@@ -1822,6 +1843,62 @@ export class Call {
1822
1843
  }
1823
1844
  };
1824
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
+
1825
1902
  /**
1826
1903
  * Retrieves credentials for joining the call.
1827
1904
  *
@@ -2063,6 +2140,16 @@ export class Call {
2063
2140
  return;
2064
2141
  }
2065
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
+ }
2066
2153
 
2067
2154
  // we don't increment reconnect attempts for the FAST strategy.
2068
2155
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
@@ -2343,7 +2430,12 @@ export class Call {
2343
2430
  });
2344
2431
 
2345
2432
  // handles the "error" event, through which the SFU can request a reconnect
2346
- 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;
2347
2439
  const { reconnectStrategy: strategy, error } = e;
2348
2440
  if (!SfuJoinError.isJoinErrorCode(e)) {
2349
2441
  const code = error?.code ? ErrorCode[error.code] : undefined;
@@ -2363,10 +2455,17 @@ export class Call {
2363
2455
  this.logger.warn(`Can't leave call after disconnect request`, err);
2364
2456
  });
2365
2457
  } else {
2366
- this.reconnect(
2367
- strategy,
2368
- error?.message || ReconnectReason.SFU_ERROR,
2369
- ).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) => {
2370
2469
  this.logger.warn('[Reconnect] Error reconnecting', err);
2371
2470
  });
2372
2471
  }
@@ -3804,9 +3903,11 @@ export class Call {
3804
3903
  * `rejoin_attempt_limit_exceeded` leave message.
3805
3904
  *
3806
3905
  * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
3807
- * 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.
3808
3908
  */
3809
3909
  setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
3910
+ this.rejoinLongWindowRateLimiter = undefined;
3810
3911
  this.rejoinRateLimiter.setLimits(
3811
3912
  Math.max(1, maxAttempts),
3812
3913
  Math.max(1, windowSeconds) * 1000,
@@ -42,6 +42,7 @@ import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
42
42
  import { withoutConcurrency } from './helpers/concurrency';
43
43
  import { enableTimerWorker } from './timers';
44
44
  import { ClientEventReporter } from './reporting';
45
+ import { reconcileRingState } from './ringing';
45
46
 
46
47
  /**
47
48
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
@@ -285,6 +286,13 @@ export class StreamVideoClient {
285
286
 
286
287
  if (call) {
287
288
  call.updateFromCallStateResponse(c);
289
+ // ring events missed while the WS was down aren't replayed
290
+ reconcileRingState(call, 'ring-poll-api').catch((err) => {
291
+ call.logger.error(
292
+ 'Failed to reconcile the ring state after rewatch',
293
+ err,
294
+ );
295
+ });
288
296
  }
289
297
  }
290
298
 
@@ -12,7 +12,9 @@ import type {
12
12
  CallResponse,
13
13
  ConnectedEvent,
14
14
  GetCallResponse,
15
+ MemberResponse,
15
16
  QueryCallsResponse,
17
+ UserResponse,
16
18
  } from '../gen/coordinator';
17
19
 
18
20
  const apiKey = 'mock-api-key';
@@ -126,6 +128,140 @@ describe('StreamVideoClient re-watching calls on reconnect', () => {
126
128
  await vi.waitFor(() => expect(leave).toHaveBeenCalled());
127
129
  });
128
130
 
131
+ describe('ring reconciliation after a rewatch', () => {
132
+ const rewatchWith = async (
133
+ call: Call,
134
+ rejected_by: Record<string, string>,
135
+ extra: {
136
+ accepted_by?: Record<string, string>;
137
+ members?: MemberResponse[];
138
+ ended_at?: string;
139
+ } = {},
140
+ ) => {
141
+ const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
142
+ const session = CallRingPayload.call.session!;
143
+ const response = queryCallsResponse({
144
+ ...CallRingPayload.call,
145
+ // keep the creator the call was set up with
146
+ created_by: call.state.createdBy as UserResponse,
147
+ ended_at: extra.ended_at,
148
+ session: {
149
+ ...session,
150
+ rejected_by,
151
+ accepted_by: extra.accepted_by ?? session.accepted_by,
152
+ },
153
+ });
154
+ if (extra.members) response.calls[0].members = extra.members;
155
+ const post = vi
156
+ .spyOn(client.streamClient, 'doAxiosRequest')
157
+ .mockResolvedValue(response as never);
158
+ const updated = vi.spyOn(call, 'updateFromCallStateResponse');
159
+ reconnect();
160
+ await vi.waitFor(() => expect(post).toHaveBeenCalled());
161
+ await vi.waitFor(() => expect(updated).toHaveBeenCalled());
162
+ for (let i = 0; i < 5; i++) await Promise.resolve();
163
+ return leave;
164
+ };
165
+ const rejectedAt = () => '2025-08-14T14:49:00Z';
166
+
167
+ it('callee leaves when the creator cancelled while offline', async () => {
168
+ const call = await setupRingingCall();
169
+ const leave = await rewatchWith(call, { oliver_1: rejectedAt() });
170
+ expect(leave).toHaveBeenCalledWith({
171
+ reason: 'ended',
172
+ message: 'ring: creator rejected',
173
+ });
174
+ });
175
+
176
+ it('callee keeps ringing without a rejection', async () => {
177
+ const call = await setupRingingCall();
178
+ const leave = await rewatchWith(call, {});
179
+ expect(leave).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('callee keeps ringing when only another member rejected', async () => {
183
+ const call = await setupRingingCall();
184
+ const leave = await rewatchWith(call, { someone_else: rejectedAt() });
185
+ expect(leave).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('callee leaves when the call ended while offline', async () => {
189
+ const call = await setupRingingCall();
190
+ const leave = await rewatchWith(call, {}, { ended_at: rejectedAt() });
191
+ expect(leave).toHaveBeenCalledWith({
192
+ reason: 'ended',
193
+ message: 'ring: call ended',
194
+ });
195
+ });
196
+
197
+ it('does not reconcile a call that is not in RINGING state', async () => {
198
+ const call = await setupRingingCall();
199
+ call.state.setCallingState(CallingState.JOINED);
200
+ const leave = await rewatchWith(call, { oliver_1: rejectedAt() });
201
+ expect(leave).not.toHaveBeenCalled();
202
+ });
203
+
204
+ describe('as the caller', () => {
205
+ const callee = CallRingPayload.members[0];
206
+ const members = [
207
+ callee,
208
+ {
209
+ ...callee,
210
+ user_id: 'oliver_1',
211
+ user: { ...callee.user, id: 'oliver_1' },
212
+ },
213
+ ];
214
+
215
+ const setupOwnRingingCall = async () => {
216
+ const payload = {
217
+ ...CallRingPayload,
218
+ call: {
219
+ ...CallRingPayload.call,
220
+ created_by: { ...CallRingPayload.call.created_by, id: userId },
221
+ },
222
+ members,
223
+ };
224
+ vi.spyOn(client.streamClient, 'doAxiosRequest').mockResolvedValue({
225
+ duration: '1ms',
226
+ call: payload.call,
227
+ members,
228
+ own_capabilities: [],
229
+ } as GetCallResponse as never);
230
+ client.streamClient.dispatchEvent(payload as StreamVideoEvent);
231
+ await settled(getCallInitConcurrencyTag(payload.call_cid));
232
+ const [call] = client.state.calls;
233
+ expect(call.isCreatedByMe).toBeTruthy();
234
+ expect(call.state.callingState).toBe(CallingState.RINGING);
235
+ return call;
236
+ };
237
+
238
+ it('leaves when every other member rejected while offline', async () => {
239
+ const call = await setupOwnRingingCall();
240
+ const leave = await rewatchWith(
241
+ call,
242
+ { oliver_1: rejectedAt() },
243
+ { members },
244
+ );
245
+ expect(leave).toHaveBeenCalledWith({
246
+ reject: true,
247
+ reason: 'cancel',
248
+ message: 'ring: everyone rejected',
249
+ });
250
+ });
251
+
252
+ it('joins when another member accepted while offline', async () => {
253
+ const call = await setupOwnRingingCall();
254
+ const join = vi.spyOn(call, 'join').mockResolvedValue(undefined);
255
+ await rewatchWith(
256
+ call,
257
+ {},
258
+ { members, accepted_by: { oliver_1: rejectedAt() } },
259
+ );
260
+ expect(join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
261
+ });
262
+ });
263
+ });
264
+
129
265
  it('queryCalls returns an independent instance for registered calls', async () => {
130
266
  // e.g. being on a call while watching a dashboard of calls:
131
267
  // leaving the joined instance must not silence the dashboard instance
@@ -202,7 +202,7 @@ export type Logger = (
202
202
 
203
203
  export type RingStatePollingOptions = {
204
204
  /**
205
- * Quiet time after the ring starts, before the first poll. Defaults to 15_000.
205
+ * Quiet time after the ring starts, before the first poll. Defaults to 9_000.
206
206
  */
207
207
  startAfterMs?: number;
208
208
 
@@ -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) =>
@@ -837,11 +843,30 @@ export abstract class DeviceManager<
837
843
  const devices = getCurrentValue(this.listDevices()) || [];
838
844
  const currentDevice =
839
845
  this.findDevice(devices, selectedDevice) ??
840
- createSyntheticDevice(selectedDevice, deviceKind);
846
+ createSyntheticDevice(
847
+ selectedDevice,
848
+ deviceKind,
849
+ this.getActiveTrackLabel(selectedDevice),
850
+ );
841
851
 
842
852
  writePreferences(currentDevice, deviceKey, muted, storageKey);
843
853
  }
844
854
 
855
+ /**
856
+ * Returns the label of the active track if it was captured from the given
857
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
858
+ * so the persisted preference still carries the device label.
859
+ */
860
+ private getActiveTrackLabel = (deviceId: string) => {
861
+ const stream = this.state.rootMediaStream;
862
+ const tracks =
863
+ this.trackType === TrackType.AUDIO
864
+ ? stream?.getAudioTracks()
865
+ : stream?.getVideoTracks();
866
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
867
+ return track?.label;
868
+ };
869
+
845
870
  protected async applyPersistedPreferences(
846
871
  enabledInCallType: boolean,
847
872
  forceDisabled = false,
@@ -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);