@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
@@ -202,6 +202,39 @@ describe('reconcileRingState', () => {
202
202
  message: 'ring: call ended',
203
203
  });
204
204
  });
205
+
206
+ describe('as the callee', () => {
207
+ const ended = { reason: 'ended', message: 'ring: call ended' };
208
+
209
+ it('leaves when the session has ended', async () => {
210
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
211
+ setSession(call, { ended_at: timestamp() });
212
+
213
+ expect(await reconcile(call)).toBe(true);
214
+ expect(call.leave).toHaveBeenCalledWith(ended);
215
+ });
216
+
217
+ it('leaves when the call itself has ended', async () => {
218
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
219
+ setSession(call, {});
220
+ call.state.setEndedAt(new Date());
221
+
222
+ expect(await reconcile(call)).toBe(true);
223
+ expect(call.leave).toHaveBeenCalledWith(ended);
224
+ });
225
+
226
+ it('leaves once with the ended message when the creator also rejected', async () => {
227
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
228
+ setSession(call, {
229
+ ended_at: timestamp(),
230
+ rejected_by: { m1: timestamp() },
231
+ });
232
+
233
+ expect(await reconcile(call)).toBe(true);
234
+ expect(call.leave).toHaveBeenCalledTimes(1);
235
+ expect(call.leave).toHaveBeenCalledWith(ended);
236
+ });
237
+ });
205
238
  });
206
239
 
207
240
  it('is terminal once the call is no longer ringing', async () => {
@@ -6,15 +6,16 @@ import type { CallLeaveOptions } from '../types';
6
6
  /**
7
7
  * Decides what a ringing call should do next, based on the current call state.
8
8
  *
9
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10
- * state poller both run this. They differ only in how the state got there: the
11
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
12
- * poller applies the polled ring state itself.
9
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
10
+ * the rewatch after a WS reconnect all run this. They differ only in how the
11
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
12
+ * before them, while the poller applies the polled ring state itself and the
13
+ * rewatch applies the queried call state.
13
14
  *
14
15
  * @param call the call to reconcile.
15
- * @param joinSource which of the two triggered this run, reported on the
16
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
17
- * poller.
16
+ * @param joinSource what triggered this run, reported on the caller's join:
17
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
18
+ * rewatch (both come from an API response).
18
19
  * @returns whether the ring reached a terminal state. A failed join is not
19
20
  * terminal: the caller should keep trying while the ring is open.
20
21
  */
@@ -79,10 +80,17 @@ const reconcileAsCaller = async (
79
80
  };
80
81
 
81
82
  // the current user's own accept or reject, on this or another device, is
82
- // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
83
+ // handled by `resolveOwnRingOutcome`, and a live `call.ended` by
84
+ // `watchCallEnded`. An end missed while offline is handled here.
83
85
  const reconcileAsCallee = async (call: Call): Promise<boolean> => {
84
- const createdById = call.state.createdBy?.id;
85
- const rejectedBy = call.state.session?.rejected_by ?? {};
86
+ const { session, endedAt, createdBy } = call.state;
87
+ if (endedAt || session?.ended_at) {
88
+ call.logger.info('ring: the call has ended, leaving');
89
+ return leave(call, { reason: 'ended', message: 'ring: call ended' });
90
+ }
91
+
92
+ const createdById = createdBy?.id;
93
+ const rejectedBy = session?.rejected_by ?? {};
86
94
  if (createdById && rejectedBy[createdById]) {
87
95
  call.logger.info('ring: the caller cancelled, leaving');
88
96
  return leave(call, {
@@ -19,6 +19,7 @@ import { toJSON } from './helpers/iceCandiates';
19
19
  import {
20
20
  BasePeerConnectionOpts,
21
21
  OnIceConnected,
22
+ OnNoLocalCandidates,
22
23
  OnPeerConnectionStateChange,
23
24
  OnReconnectionNeeded,
24
25
  OnRemoteTrackUnmute,
@@ -43,10 +44,12 @@ export abstract class BasePeerConnection {
43
44
 
44
45
  protected onReconnectionNeeded?: OnReconnectionNeeded;
45
46
  private onIceConnected?: OnIceConnected;
47
+ private onNoLocalCandidates?: OnNoLocalCandidates;
46
48
  private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
47
49
  protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
48
50
  private readonly iceRestartDelay: number;
49
51
  private iceHasEverConnected = false;
52
+ private localCandidatesGathered = 0;
50
53
  private iceRestartTimeout?: NodeJS.Timeout;
51
54
  private preConnectStuckTimeout?: NodeJS.Timeout;
52
55
  protected isIceRestarting = false;
@@ -73,6 +76,7 @@ export abstract class BasePeerConnection {
73
76
  dispatcher,
74
77
  onReconnectionNeeded,
75
78
  onIceConnected,
79
+ onNoLocalCandidates,
76
80
  onPeerConnectionStateChange,
77
81
  onRemoteTrackUnmute,
78
82
  tag,
@@ -93,6 +97,7 @@ export abstract class BasePeerConnection {
93
97
  this.tag = tag;
94
98
  this.onReconnectionNeeded = onReconnectionNeeded;
95
99
  this.onIceConnected = onIceConnected;
100
+ this.onNoLocalCandidates = onNoLocalCandidates;
96
101
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
97
102
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
98
103
  this.logger = videoLoggerSystem.getLogger(
@@ -148,6 +153,7 @@ export abstract class BasePeerConnection {
148
153
  this.preConnectStuckTimeout = undefined;
149
154
  this.onReconnectionNeeded = undefined;
150
155
  this.onIceConnected = undefined;
156
+ this.onNoLocalCandidates = undefined;
151
157
  this.onPeerConnectionStateChange = undefined;
152
158
  this.onRemoteTrackUnmute = undefined;
153
159
  this.isDisposed = true;
@@ -285,6 +291,20 @@ export abstract class BasePeerConnection {
285
291
  return this.trackIdToTrackType.get(trackId);
286
292
  };
287
293
 
294
+ /**
295
+ * Returns `true` once ICE has reached `connected`/`completed` at least
296
+ * once during the lifetime of this peer connection.
297
+ */
298
+ hasIceEverConnected = () => this.iceHasEverConnected;
299
+
300
+ /**
301
+ * Returns `true` when the latest ICE gathering round completed without
302
+ * producing a single local candidate.
303
+ */
304
+ hasNoLocalCandidates = () =>
305
+ this.pc.iceGatheringState === 'complete' &&
306
+ this.localCandidatesGathered === 0;
307
+
288
308
  /**
289
309
  * Checks if the `RTCPeerConnection` is healthy.
290
310
  * It checks the ICE connection state and the peer connection state.
@@ -311,6 +331,7 @@ export abstract class BasePeerConnection {
311
331
  this.logger.debug('null ice candidate');
312
332
  return;
313
333
  }
334
+ if (candidate.candidate) this.localCandidatesGathered++;
314
335
 
315
336
  const iceCandidate = toJSON(candidate);
316
337
  this.sfuClient
@@ -389,6 +410,20 @@ export abstract class BasePeerConnection {
389
410
  private handleConnectionStateUpdate = (
390
411
  state: RTCIceConnectionState | RTCPeerConnectionState,
391
412
  ) => {
413
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
414
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
415
+ // ahead of the guards below so a connection established while offline,
416
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
417
+ // the unsupported-network failure counter only after WebRTC has actually
418
+ // recovered, not merely on SFU join.
419
+ if (
420
+ (state === 'connected' || state === 'completed') &&
421
+ !this.iceHasEverConnected
422
+ ) {
423
+ this.iceHasEverConnected = true;
424
+ this.onIceConnected?.(this.peerType);
425
+ }
426
+
392
427
  const { callingState } = this.state;
393
428
  if (callingState === CallingState.OFFLINE) return;
394
429
  if (callingState === CallingState.RECONNECTING) return;
@@ -466,14 +501,6 @@ export abstract class BasePeerConnection {
466
501
 
467
502
  case 'connected':
468
503
  case 'completed':
469
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
470
- // the first time ICE reaches `connected`/`completed` end-to-end.
471
- // Used by `Call` to reset the unsupported-network failure counter
472
- // only after WebRTC has actually recovered, not merely on SFU join.
473
- if (!this.iceHasEverConnected) {
474
- this.iceHasEverConnected = true;
475
- this.onIceConnected?.(this.peerType);
476
- }
477
504
  // clear any scheduled restartICE since the connection is healthy
478
505
  if (this.iceRestartTimeout) {
479
506
  this.logger.info('connected connection, canceling restartICE');
@@ -502,7 +529,18 @@ export abstract class BasePeerConnection {
502
529
  * Handles the ICE gathering state change event.
503
530
  */
504
531
  private onIceGatherChange = () => {
505
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
532
+ const state = this.pc.iceGatheringState;
533
+ this.logger.debug(`ICE Gathering State`, state);
534
+ if (state === 'gathering') {
535
+ this.localCandidatesGathered = 0;
536
+ } else if (
537
+ state === 'complete' &&
538
+ this.localCandidatesGathered === 0 &&
539
+ !this.iceHasEverConnected
540
+ ) {
541
+ this.logger.info('ICE gathering completed without local candidates');
542
+ this.onNoLocalCandidates?.(this.peerType);
543
+ }
506
544
  };
507
545
 
508
546
  /**