@stream-io/video-client 1.48.0 → 1.50.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 (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -13,7 +13,12 @@ import { StreamSfuClient } from '../StreamSfuClient';
13
13
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
14
14
  import { withoutConcurrency } from '../helpers/concurrency';
15
15
  import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
16
- import type { BasePeerConnectionOpts, OnReconnectionNeeded } from './types';
16
+ import {
17
+ BasePeerConnectionOpts,
18
+ OnIceConnected,
19
+ OnReconnectionNeeded,
20
+ ReconnectReason,
21
+ } from './types';
17
22
  import type { ClientPublishOptions } from '../types';
18
23
 
19
24
  /**
@@ -31,8 +36,11 @@ export abstract class BasePeerConnection {
31
36
  protected sfuClient: StreamSfuClient;
32
37
 
33
38
  private onReconnectionNeeded?: OnReconnectionNeeded;
39
+ private onIceConnected?: OnIceConnected;
34
40
  private readonly iceRestartDelay: number;
41
+ private iceHasEverConnected = false;
35
42
  private iceRestartTimeout?: NodeJS.Timeout;
43
+ private preConnectStuckTimeout?: NodeJS.Timeout;
36
44
  protected isIceRestarting = false;
37
45
  private isDisposed = false;
38
46
 
@@ -56,6 +64,7 @@ export abstract class BasePeerConnection {
56
64
  state,
57
65
  dispatcher,
58
66
  onReconnectionNeeded,
67
+ onIceConnected,
59
68
  tag,
60
69
  enableTracing,
61
70
  clientPublishOptions,
@@ -70,6 +79,7 @@ export abstract class BasePeerConnection {
70
79
  this.clientPublishOptions = clientPublishOptions;
71
80
  this.tag = tag;
72
81
  this.onReconnectionNeeded = onReconnectionNeeded;
82
+ this.onIceConnected = onIceConnected;
73
83
  this.logger = videoLoggerSystem.getLogger(
74
84
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
75
85
  { tags: [tag] },
@@ -108,7 +118,10 @@ export abstract class BasePeerConnection {
108
118
  dispose() {
109
119
  clearTimeout(this.iceRestartTimeout);
110
120
  this.iceRestartTimeout = undefined;
121
+ clearTimeout(this.preConnectStuckTimeout);
122
+ this.preConnectStuckTimeout = undefined;
111
123
  this.onReconnectionNeeded = undefined;
124
+ this.onIceConnected = undefined;
112
125
  this.isDisposed = true;
113
126
  this.detachEventHandlers();
114
127
  this.pc.close();
@@ -145,14 +158,17 @@ export abstract class BasePeerConnection {
145
158
  */
146
159
  protected tryRestartIce = () => {
147
160
  this.restartIce().catch((e) => {
148
- const reason = 'restartICE() failed, initiating reconnect';
149
- this.logger.error(reason, e);
161
+ this.logger.error('restartICE() failed, initiating reconnect', e);
150
162
  const strategy =
151
163
  e instanceof NegotiationError &&
152
164
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
153
165
  ? WebsocketReconnectStrategy.FAST
154
166
  : WebsocketReconnectStrategy.REJOIN;
155
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
167
+ this.onReconnectionNeeded?.(
168
+ strategy,
169
+ ReconnectReason.RESTART_ICE_FAILED,
170
+ this.peerType,
171
+ );
156
172
  });
157
173
  };
158
174
 
@@ -239,6 +255,20 @@ export abstract class BasePeerConnection {
239
255
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
240
256
  };
241
257
 
258
+ /**
259
+ * Returns true only when the peer connection is currently fully established
260
+ * (ICE `connected`/`completed` AND connection state `connected`).
261
+ * Transient states like `disconnected`, `checking`, or `new` return false.
262
+ */
263
+ isStable = () => {
264
+ const iceState = this.pc.iceConnectionState;
265
+ const connectionState = this.pc.connectionState;
266
+ return (
267
+ (iceState === 'connected' || iceState === 'completed') &&
268
+ connectionState === 'connected'
269
+ );
270
+ };
271
+
242
272
  /**
243
273
  * Handles the ICECandidate event and
244
274
  * Initiates an ICE Trickle process with the SFU.
@@ -292,7 +322,7 @@ export abstract class BasePeerConnection {
292
322
  if (state === 'failed') {
293
323
  this.onReconnectionNeeded?.(
294
324
  WebsocketReconnectStrategy.REJOIN,
295
- 'Connection failed',
325
+ ReconnectReason.CONNECTION_FAILED,
296
326
  this.peerType,
297
327
  );
298
328
  return;
@@ -320,6 +350,54 @@ export abstract class BasePeerConnection {
320
350
  // do nothing when ICE is restarting
321
351
  if (this.isIceRestarting) return;
322
352
 
353
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
354
+ // Restart is futile here (the data plane was never established), but
355
+ // these two terminal-ish states need different treatment:
356
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
357
+ // /PC configuration gets a chance, and let `Call.reconnect` count
358
+ // this toward the unsupported-network budget.
359
+ // - `disconnected` is transient, the browser may yet move back to
360
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
361
+ // out. If it ultimately fails, ICE will transition to `failed` and
362
+ // the branch above will take over.
363
+ if (!this.iceHasEverConnected) {
364
+ if (state === 'failed') {
365
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
366
+ clearTimeout(this.preConnectStuckTimeout);
367
+ this.preConnectStuckTimeout = undefined;
368
+ this.onReconnectionNeeded?.(
369
+ WebsocketReconnectStrategy.REJOIN,
370
+ ReconnectReason.ICE_NEVER_CONNECTED,
371
+ this.peerType,
372
+ );
373
+ return;
374
+ }
375
+ if (state === 'disconnected') {
376
+ this.logger.info('ICE disconnected before connected, wait to recover');
377
+ // Watchdog: if the browser stays in `disconnected` without ever
378
+ // reaching `connected` or transitioning to `failed`, escalate to
379
+ // REJOIN ourselves so we don't wait silently forever. Rare but
380
+ // observed on flaky mobile networks.
381
+ clearTimeout(this.preConnectStuckTimeout);
382
+ this.preConnectStuckTimeout = setTimeout(() => {
383
+ if (
384
+ !this.iceHasEverConnected &&
385
+ this.pc.iceConnectionState === 'disconnected'
386
+ ) {
387
+ this.logger.info(
388
+ 'ICE stuck in pre-connect disconnected, escalating to REJOIN',
389
+ );
390
+ this.onReconnectionNeeded?.(
391
+ WebsocketReconnectStrategy.REJOIN,
392
+ ReconnectReason.ICE_NEVER_CONNECTED,
393
+ this.peerType,
394
+ );
395
+ }
396
+ }, this.iceRestartDelay * 2);
397
+ return;
398
+ }
399
+ }
400
+
323
401
  switch (state) {
324
402
  case 'failed':
325
403
  // in the `failed` state, we try to restart ICE immediately
@@ -341,12 +419,24 @@ export abstract class BasePeerConnection {
341
419
  break;
342
420
 
343
421
  case 'connected':
344
- // in the `connected` state, we clear the ice restart timeout if it exists
422
+ case 'completed':
423
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
424
+ // the first time ICE reaches `connected`/`completed` end-to-end.
425
+ // Used by `Call` to reset the unsupported-network failure counter
426
+ // only after WebRTC has actually recovered, not merely on SFU join.
427
+ if (!this.iceHasEverConnected) {
428
+ this.iceHasEverConnected = true;
429
+ this.onIceConnected?.(this.peerType);
430
+ }
431
+ // clear any scheduled restartICE since the connection is healthy
345
432
  if (this.iceRestartTimeout) {
346
433
  this.logger.info('connected connection, canceling restartICE');
347
434
  clearTimeout(this.iceRestartTimeout);
348
435
  this.iceRestartTimeout = undefined;
349
436
  }
437
+ // clear the pre-connect watchdog if it was armed
438
+ clearTimeout(this.preConnectStuckTimeout);
439
+ this.preConnectStuckTimeout = undefined;
350
440
  break;
351
441
  }
352
442
  };
@@ -20,6 +20,7 @@ import {
20
20
  toVideoLayers,
21
21
  } from './layers';
22
22
  import { isSvcCodec } from './codecs';
23
+ import { toRTCDegradationPreference } from './helpers/degradationPreference';
23
24
  import { isAudioTrackType } from './helpers/tracks';
24
25
  import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
25
26
  import { withoutConcurrency } from '../helpers/concurrency';
@@ -135,7 +136,9 @@ export class Publisher extends BasePeerConnection {
135
136
  });
136
137
 
137
138
  const params = transceiver.sender.getParameters();
138
- params.degradationPreference = 'maintain-framerate';
139
+ params.degradationPreference =
140
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
141
+ 'maintain-framerate';
139
142
  await transceiver.sender.setParameters(params);
140
143
 
141
144
  const trackType = publishOption.trackType;
@@ -248,6 +251,38 @@ export class Publisher extends BasePeerConnection {
248
251
  return false;
249
252
  };
250
253
 
254
+ /**
255
+ * Re-arms the encoder for the given track type by detaching and
256
+ * reattaching the currently published track on each matching sender.
257
+ *
258
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
259
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
260
+ * can stop producing RTP packets even though the underlying
261
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
262
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
263
+ * sender's encoder pipeline without renegotiation, restoring packet
264
+ * flow with the same SSRC.
265
+ *
266
+ * No-op when nothing is published for the given track type.
267
+ *
268
+ * @param trackType the track type to refresh.
269
+ */
270
+ refreshTrack = async (trackType: TrackType) => {
271
+ for (const item of this.transceiverCache.items()) {
272
+ if (item.publishOption.trackType !== trackType) continue;
273
+ const { sender } = item.transceiver;
274
+ const track = sender.track;
275
+ if (!track || track.readyState !== 'live') continue;
276
+ try {
277
+ await sender.replaceTrack(null);
278
+ await sender.replaceTrack(track);
279
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
280
+ } catch (err) {
281
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
282
+ }
283
+ }
284
+ };
285
+
251
286
  /**
252
287
  * Stops the cloned track that is being published to the SFU.
253
288
  */
@@ -344,6 +379,17 @@ export class Publisher extends BasePeerConnection {
344
379
  }
345
380
  }
346
381
 
382
+ const degradationPreference = toRTCDegradationPreference(
383
+ videoSender.degradationPreference,
384
+ );
385
+ if (
386
+ degradationPreference &&
387
+ params.degradationPreference !== degradationPreference
388
+ ) {
389
+ params.degradationPreference = degradationPreference;
390
+ changed = true;
391
+ }
392
+
347
393
  const activeEncoders = params.encodings.filter((e) => e.active);
348
394
  if (!changed) {
349
395
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -460,7 +506,8 @@ export class Publisher extends BasePeerConnection {
460
506
  const trackInfos: TrackInfo[] = [];
461
507
  for (const publishOption of this.publishOptions) {
462
508
  const bundle = this.transceiverCache.get(publishOption);
463
- if (!bundle || !bundle.transceiver.sender.track) continue;
509
+ const track = bundle?.transceiver.sender.track;
510
+ if (!bundle || !track || track.readyState !== 'live') continue;
464
511
  trackInfos.push(this.toTrackInfo(bundle, sdp));
465
512
  }
466
513
  return trackInfos;
@@ -1,9 +1,10 @@
1
1
  import { BasePeerConnection } from './BasePeerConnection';
2
2
  import { BasePeerConnectionOpts } from './types';
3
3
  import { NegotiationError } from './NegotiationError';
4
- import { PeerType } from '../gen/video/sfu/models/models';
4
+ import { PeerType, TrackType } from '../gen/video/sfu/models/models';
5
5
  import { SubscriberOffer } from '../gen/video/sfu/event/events';
6
6
  import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
7
+ import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
7
8
  import { enableStereo, removeCodecsExcept } from './helpers/sdp';
8
9
 
9
10
  /**
@@ -67,7 +68,8 @@ export class Subscriber extends BasePeerConnection {
67
68
  };
68
69
 
69
70
  private handleOnTrack = (e: RTCTrackEvent) => {
70
- const [primaryStream] = e.streams;
71
+ const { streams, track } = e;
72
+ const [primaryStream] = streams;
71
73
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
72
74
  const [trackId, rawTrackType] = primaryStream.id.split(':');
73
75
  const participantToUpdate = this.state.participants.find(
@@ -75,30 +77,35 @@ export class Subscriber extends BasePeerConnection {
75
77
  );
76
78
  this.logger.debug(
77
79
  `[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
78
- e.track.id,
79
- e.track,
80
+ track.id,
81
+ track,
80
82
  );
81
83
 
84
+ const trackType = toTrackType(rawTrackType);
85
+ if (!trackType) {
86
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
87
+ }
88
+
82
89
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
83
- e.track.addEventListener('mute', () => {
90
+ track.addEventListener('mute', () => {
84
91
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
92
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
85
93
  });
86
-
87
- e.track.addEventListener('unmute', () => {
94
+ track.addEventListener('unmute', () => {
88
95
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
96
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
89
97
  });
90
-
91
- e.track.addEventListener('ended', () => {
98
+ track.addEventListener('ended', () => {
92
99
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
100
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
93
101
  this.state.removeOrphanedTrack(primaryStream.id);
94
102
  });
95
103
 
96
- const trackType = toTrackType(rawTrackType);
97
- if (!trackType) {
98
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
104
+ if (track.muted) {
105
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
99
106
  }
100
107
 
101
- this.trackIdToTrackType.set(e.track.id, trackType);
108
+ this.trackIdToTrackType.set(track.id, trackType);
102
109
 
103
110
  if (!participantToUpdate) {
104
111
  this.logger.warn(
@@ -133,7 +140,7 @@ export class Subscriber extends BasePeerConnection {
133
140
  // now, dispose the previous stream if it exists
134
141
  if (previousStream) {
135
142
  this.logger.info(
136
- `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`,
143
+ `[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
137
144
  );
138
145
  previousStream.getTracks().forEach((t) => {
139
146
  t.stop();
@@ -142,6 +149,27 @@ export class Subscriber extends BasePeerConnection {
142
149
  }
143
150
  };
144
151
 
152
+ private setRemoteTrackInterrupted = (
153
+ trackId: string,
154
+ trackType: TrackType,
155
+ interrupted: boolean,
156
+ ) => {
157
+ if (trackType !== TrackType.AUDIO) return;
158
+ const target = this.state.participants.find(
159
+ (p) => p.trackLookupPrefix === trackId,
160
+ );
161
+ if (!target) return;
162
+ this.state.updateParticipant(target.sessionId, (p) => {
163
+ const current = p.interruptedTracks ?? [];
164
+ const has = current.includes(trackType);
165
+ if (interrupted === has) return {};
166
+ const next = interrupted
167
+ ? pushToIfMissing([...current], trackType)
168
+ : removeFromIfPresent([...current], trackType);
169
+ return { interruptedTracks: next };
170
+ });
171
+ };
172
+
145
173
  private negotiate = async (subscriberOffer: SubscriberOffer) => {
146
174
  await this.pc.setRemoteDescription({
147
175
  type: 'offer',