@stream-io/video-client 1.49.0 → 1.51.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -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/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
@@ -20,10 +20,15 @@ import {
20
20
  toVideoLayers,
21
21
  } from './layers';
22
22
  import { isSvcCodec } from './codecs';
23
+ import {
24
+ fromRTCDegradationPreference,
25
+ toRTCDegradationPreference,
26
+ } from './helpers/degradationPreference';
23
27
  import { isAudioTrackType } from './helpers/tracks';
24
28
  import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
25
29
  import { withoutConcurrency } from '../helpers/concurrency';
26
30
  import { isReactNative } from '../helpers/platforms';
31
+ import { isFirefox } from '../helpers/browsers';
27
32
 
28
33
  /**
29
34
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
@@ -52,7 +57,16 @@ export class Publisher extends BasePeerConnection {
52
57
 
53
58
  this.on('changePublishQuality', async (event) => {
54
59
  for (const videoSender of event.videoSenders) {
55
- await this.changePublishQuality(videoSender);
60
+ // if not publishing, update the encodingConfigCache and don't modify the state.
61
+ // we'll apply this config on the next publish/unmute.
62
+ const { trackType, publishOptionId } = videoSender;
63
+ const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
64
+ if (bundle) {
65
+ this.transceiverCache.update(bundle.publishOption, { videoSender });
66
+ }
67
+ if (isFirefox() && !this.isPublishing(trackType)) continue;
68
+
69
+ await this.changePublishQuality(videoSender, bundle);
56
70
  }
57
71
  });
58
72
 
@@ -65,9 +79,13 @@ export class Publisher extends BasePeerConnection {
65
79
  /**
66
80
  * Disposes this Publisher instance.
67
81
  */
68
- dispose() {
69
- super.dispose();
70
- this.stopAllTracks();
82
+ async dispose(): Promise<void> {
83
+ await super.dispose();
84
+ try {
85
+ await this.stopAllTracks();
86
+ } catch (err) {
87
+ this.logger.warn('Failed to stop tracks during dispose', err);
88
+ }
71
89
  this.clonedTracks.clear();
72
90
  }
73
91
 
@@ -97,17 +115,12 @@ export class Publisher extends BasePeerConnection {
97
115
  // appear in the SDP in multiple transceivers
98
116
  const trackToPublish = this.cloneTrack(track);
99
117
 
100
- const { transceiver } = this.transceiverCache.get(publishOption) || {};
101
- if (!transceiver) {
118
+ const bundle = this.transceiverCache.get(publishOption);
119
+ if (!bundle) {
102
120
  await this.addTransceiver(trackToPublish, publishOption, options);
103
121
  } else {
104
- const previousTrack = transceiver.sender.track;
105
- await this.updateTransceiver(
106
- transceiver,
107
- trackToPublish,
108
- trackType,
109
- options,
110
- );
122
+ const previousTrack = bundle.transceiver.sender.track;
123
+ await this.updateTransceiver(bundle, trackToPublish, options);
111
124
  if (!isReactNative()) {
112
125
  this.stopTrack(previousTrack);
113
126
  }
@@ -135,7 +148,9 @@ export class Publisher extends BasePeerConnection {
135
148
  });
136
149
 
137
150
  const params = transceiver.sender.getParameters();
138
- params.degradationPreference = 'maintain-framerate';
151
+ params.degradationPreference =
152
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
153
+ 'maintain-framerate';
139
154
  await transceiver.sender.setParameters(params);
140
155
 
141
156
  const trackType = publishOption.trackType;
@@ -150,15 +165,22 @@ export class Publisher extends BasePeerConnection {
150
165
  * Updates the transceiver with the given track and track type.
151
166
  */
152
167
  private updateTransceiver = async (
153
- transceiver: RTCRtpTransceiver,
168
+ bundle: PublishBundle,
154
169
  track: MediaStreamTrack | null,
155
- trackType: TrackType,
156
170
  options: TrackPublishOptions = {},
157
171
  ) => {
172
+ const { transceiver, publishOption } = bundle;
173
+ const trackType = publishOption.trackType;
158
174
  const sender = transceiver.sender;
159
175
  if (sender.track) this.trackIdToTrackType.delete(sender.track.id);
160
176
  await sender.replaceTrack(track);
161
- if (track) this.trackIdToTrackType.set(track.id, trackType);
177
+ if (track) {
178
+ this.trackIdToTrackType.set(track.id, trackType);
179
+ if (isFirefox() && bundle.videoSender) {
180
+ // restore the encoding config from the cache, if any
181
+ await this.changePublishQuality(bundle.videoSender, bundle);
182
+ }
183
+ }
162
184
  if (isAudioTrackType(trackType)) {
163
185
  await this.updateAudioPublishOptions(trackType, options);
164
186
  }
@@ -227,7 +249,7 @@ export class Publisher extends BasePeerConnection {
227
249
  if (hasPublishOption) continue;
228
250
  // it is safe to stop the track here, it is a clone
229
251
  this.stopTrack(transceiver.sender.track);
230
- await this.updateTransceiver(transceiver, null, publishOption.trackType);
252
+ await this.updateTransceiver(item, null);
231
253
  }
232
254
  };
233
255
 
@@ -249,41 +271,84 @@ export class Publisher extends BasePeerConnection {
249
271
  };
250
272
 
251
273
  /**
252
- * Stops the cloned track that is being published to the SFU.
274
+ * Re-arms the encoder for the given track type by detaching and
275
+ * reattaching the currently published track on each matching sender.
276
+ *
277
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
278
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
279
+ * can stop producing RTP packets even though the underlying
280
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
281
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
282
+ * sender's encoder pipeline without renegotiation, restoring packet
283
+ * flow with the same SSRC.
284
+ *
285
+ * No-op when nothing is published for the given track type.
286
+ *
287
+ * @param trackType the track type to refresh.
253
288
  */
254
- stopTracks = (...trackTypes: TrackType[]) => {
289
+ refreshTrack = async (trackType: TrackType) => {
255
290
  for (const item of this.transceiverCache.items()) {
256
- const { publishOption, transceiver } = item;
257
- if (!trackTypes.includes(publishOption.trackType)) continue;
258
- this.stopTrack(transceiver.sender.track);
291
+ if (item.publishOption.trackType !== trackType) continue;
292
+ const { sender } = item.transceiver;
293
+ const track = sender.track;
294
+ if (!track || track.readyState !== 'live') continue;
295
+ try {
296
+ await sender.replaceTrack(null);
297
+ await sender.replaceTrack(track);
298
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
299
+ } catch (err) {
300
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
301
+ }
259
302
  }
260
303
  };
261
304
 
305
+ /**
306
+ * Stops the cloned track that is being published to the SFU.
307
+ */
308
+ stopTracks = async (...trackTypes: TrackType[]) => {
309
+ return withoutConcurrency(
310
+ this.eventLockKey('changePublishQuality'),
311
+ async () => {
312
+ for (const item of this.transceiverCache.items()) {
313
+ const { publishOption, transceiver } = item;
314
+ if (!trackTypes.includes(publishOption.trackType)) continue;
315
+ const track = transceiver.sender.track;
316
+ await this.silenceSenderOnFirefox(item);
317
+ this.stopTrack(track);
318
+ }
319
+ },
320
+ );
321
+ };
322
+
262
323
  /**
263
324
  * Stops all the cloned tracks that are being published to the SFU.
264
325
  */
265
- stopAllTracks = () => {
266
- for (const { transceiver } of this.transceiverCache.items()) {
267
- this.stopTrack(transceiver.sender.track);
268
- }
269
- for (const track of this.clonedTracks) {
270
- this.stopTrack(track);
271
- }
326
+ stopAllTracks = async () => {
327
+ return withoutConcurrency(
328
+ this.eventLockKey('changePublishQuality'),
329
+ async () => {
330
+ for (const item of this.transceiverCache.items()) {
331
+ const track = item.transceiver.sender.track;
332
+ await this.silenceSenderOnFirefox(item);
333
+ this.stopTrack(track);
334
+ }
335
+ for (const track of this.clonedTracks) {
336
+ this.stopTrack(track);
337
+ }
338
+ },
339
+ );
272
340
  };
273
341
 
274
- private changePublishQuality = async (videoSender: VideoSender) => {
275
- const { trackType, layers, publishOptionId } = videoSender;
276
- const enabledLayers = layers.filter((l) => l.active);
342
+ private changePublishQuality = async (
343
+ videoSender: VideoSender,
344
+ bundle: PublishBundle | undefined,
345
+ ) => {
346
+ const enabledLayers = videoSender.layers.filter((l) => l.active);
277
347
 
278
348
  const tag = 'Update publish quality:';
279
349
  this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
280
350
 
281
- const transceiverId = this.transceiverCache.find(
282
- (t) =>
283
- t.publishOption.id === publishOptionId &&
284
- t.publishOption.trackType === trackType,
285
- );
286
- const sender = transceiverId?.transceiver.sender;
351
+ const sender = bundle?.transceiver.sender;
287
352
  if (!sender) {
288
353
  return this.logger.warn(`${tag} no video sender found.`);
289
354
  }
@@ -293,7 +358,7 @@ export class Publisher extends BasePeerConnection {
293
358
  return this.logger.warn(`${tag} there are no encodings set.`);
294
359
  }
295
360
 
296
- const codecInUse = transceiverId?.publishOption.codec?.name;
361
+ const codecInUse = bundle?.publishOption.codec?.name;
297
362
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
298
363
 
299
364
  let changed = false;
@@ -344,6 +409,17 @@ export class Publisher extends BasePeerConnection {
344
409
  }
345
410
  }
346
411
 
412
+ const degradationPreference = toRTCDegradationPreference(
413
+ videoSender.degradationPreference,
414
+ );
415
+ if (
416
+ degradationPreference &&
417
+ params.degradationPreference !== degradationPreference
418
+ ) {
419
+ params.degradationPreference = degradationPreference;
420
+ changed = true;
421
+ }
422
+
347
423
  const activeEncoders = params.encodings.filter((e) => e.active);
348
424
  if (!changed) {
349
425
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -514,4 +590,73 @@ export class Publisher extends BasePeerConnection {
514
590
  track.stop();
515
591
  this.clonedTracks.delete(track);
516
592
  };
593
+
594
+ /**
595
+ * Silences a Firefox sender on the wire during unpublish.
596
+ *
597
+ * Firefox keeps emitting RTP after track.stop(), but the right lever
598
+ * differs by track type:
599
+ * - audio: `replaceTrack(null)` is the only reliable silencer;
600
+ * `setParameters({encodings:[...active:false]})` does NOT stop
601
+ * the Opus encoder.
602
+ * - video: `setParameters({encodings:[...active:false]})` pauses
603
+ * the encoder; `replaceTrack(null)` does NOT reliably stop the
604
+ * video encoder. The prior active=true configuration is captured
605
+ * onto `bundle.videoSender` so `updateTransceiver` can restore
606
+ * it on the next publish.
607
+ *
608
+ * No-op on non-Firefox browsers and during teardown.
609
+ */
610
+ private silenceSenderOnFirefox = async (bundle: PublishBundle) => {
611
+ if (this.isDisposed || !isFirefox()) return;
612
+ const { transceiver, publishOption } = bundle;
613
+ if (isAudioTrackType(publishOption.trackType)) {
614
+ await transceiver.sender.replaceTrack(null).catch((err) => {
615
+ this.logger.warn('Failed to clear audio sender track', err);
616
+ });
617
+ return;
618
+ }
619
+ await this.disableAllEncodings(bundle);
620
+ };
621
+
622
+ private disableAllEncodings = async (bundle: PublishBundle) => {
623
+ const { transceiver, publishOption } = bundle;
624
+ const sender = transceiver.sender;
625
+ const params = sender.getParameters();
626
+ if (!params.encodings || params.encodings.length === 0) return;
627
+
628
+ if (!bundle.videoSender) {
629
+ this.transceiverCache.update(publishOption, {
630
+ videoSender: {
631
+ trackType: publishOption.trackType,
632
+ publishOptionId: publishOption.id,
633
+ codec: publishOption.codec,
634
+ degradationPreference: fromRTCDegradationPreference(
635
+ params.degradationPreference,
636
+ ),
637
+ layers: params.encodings.map((e) => ({
638
+ name: e.rid ?? 'q',
639
+ active: e.active ?? true,
640
+ maxBitrate: e.maxBitrate ?? 0,
641
+ scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
642
+ maxFramerate: e.maxFramerate ?? 0,
643
+ // @ts-expect-error scalabilityMode is not in the typedefs yet
644
+ scalabilityMode: e.scalabilityMode ?? '',
645
+ })),
646
+ },
647
+ });
648
+ }
649
+
650
+ let changed = false;
651
+ for (const encoding of params.encodings) {
652
+ if (encoding.active !== false) {
653
+ encoding.active = false;
654
+ changed = true;
655
+ }
656
+ }
657
+ if (!changed) return;
658
+ await sender.setParameters(params).catch((err) => {
659
+ this.logger.error('Failed to disable video sender encodings:', err);
660
+ });
661
+ };
517
662
  }
@@ -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',
@@ -1,4 +1,4 @@
1
- import { PublishOption } from '../gen/video/sfu/models/models';
1
+ import { PublishOption, TrackType } from '../gen/video/sfu/models/models';
2
2
  import type { OptimalVideoLayer } from './layers';
3
3
  import type { PublishBundle, TrackLayersCache } from './types';
4
4
 
@@ -25,10 +25,17 @@ export class TransceiverCache {
25
25
  * Gets the transceiver for the given publish option.
26
26
  */
27
27
  get = (publishOption: PublishOption): PublishBundle | undefined => {
28
+ return this.getBy(publishOption.id, publishOption.trackType);
29
+ };
30
+
31
+ /**
32
+ * Gets the transceiver for the given publish option id and track type.
33
+ */
34
+ getBy = (publishOptionId: number, trackType: TrackType) => {
28
35
  return this.cache.find(
29
36
  (bundle) =>
30
- bundle.publishOption.id === publishOption.id &&
31
- bundle.publishOption.trackType === publishOption.trackType,
37
+ bundle.publishOption.id === publishOptionId &&
38
+ bundle.publishOption.trackType === trackType,
32
39
  );
33
40
  };
34
41
 
@@ -43,7 +43,7 @@ const makeCall = () => {
43
43
  */
44
44
  const primeForReconnect = (call: Call) => {
45
45
  // put the call in a non-terminal, non-JOINED state so the do-while iterates
46
- call.state.setCallingState(CallingState.JOINING);
46
+ call.state.setCallingState(CallingState.IDLE);
47
47
  // force the strategy-decider in the catch block to always pick REJOIN,
48
48
  // so tests that care about the rejoin rate limiter don't bounce to FAST
49
49
  // based on wall-clock timing. Individual tests that want to exercise the
@@ -136,7 +136,7 @@ describe('Call reconnect stopping conditions', () => {
136
136
  });
137
137
 
138
138
  for (let i = 0; i < 5; i++) {
139
- call.state.setCallingState(CallingState.JOINING);
139
+ call.state.setCallingState(CallingState.IDLE);
140
140
  await call['reconnect'](WebsocketReconnectStrategy.FAST, 'test');
141
141
  }
142
142
 
@@ -404,6 +404,125 @@ describe('Call reconnect stopping conditions', () => {
404
404
  });
405
405
  });
406
406
 
407
+ /**
408
+ * Entry-condition bails. `reconnect()` must drop new triggers when:
409
+ * - A join/reconnect/migrate lifecycle is already in progress.
410
+ * - A reconnect is already queued via `hasPending(reconnectConcurrencyTag)`.
411
+ * - The terminal `RECONNECTING_FAILED` state has been reached.
412
+ *
413
+ * These are pure short-circuits — none of the strategy implementations
414
+ * should be invoked.
415
+ */
416
+ describe('Call reconnect entry-condition bails', () => {
417
+ let call: Call;
418
+
419
+ beforeEach(() => {
420
+ call = makeCall();
421
+ vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
422
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
423
+ vi.spyOn(call, 'get').mockResolvedValue({} as never);
424
+ });
425
+
426
+ afterEach(() => {
427
+ vi.clearAllMocks();
428
+ });
429
+
430
+ const stubAllStrategies = () => ({
431
+ fast: vi
432
+ .spyOn(
433
+ call as unknown as { reconnectFast: () => Promise<void> },
434
+ 'reconnectFast',
435
+ )
436
+ .mockResolvedValue(undefined),
437
+ rejoin: vi
438
+ .spyOn(
439
+ call as unknown as { reconnectRejoin: () => Promise<void> },
440
+ 'reconnectRejoin',
441
+ )
442
+ .mockResolvedValue(undefined),
443
+ migrate: vi
444
+ .spyOn(
445
+ call as unknown as { reconnectMigrate: () => Promise<void> },
446
+ 'reconnectMigrate',
447
+ )
448
+ .mockResolvedValue(undefined),
449
+ });
450
+
451
+ it('bails immediately when state is JOINING — Call.join owns recovery during the initial join window', async () => {
452
+ const strategies = stubAllStrategies();
453
+ call.state.setCallingState(CallingState.JOINING);
454
+
455
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
456
+
457
+ expect(strategies.fast).not.toHaveBeenCalled();
458
+ expect(strategies.rejoin).not.toHaveBeenCalled();
459
+ expect(strategies.migrate).not.toHaveBeenCalled();
460
+ });
461
+
462
+ it('bails immediately when state is RECONNECTING — another reconnect is already running', async () => {
463
+ const strategies = stubAllStrategies();
464
+ call.state.setCallingState(CallingState.RECONNECTING);
465
+
466
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
467
+
468
+ expect(strategies.fast).not.toHaveBeenCalled();
469
+ expect(strategies.rejoin).not.toHaveBeenCalled();
470
+ expect(strategies.migrate).not.toHaveBeenCalled();
471
+ });
472
+
473
+ it('bails immediately when state is MIGRATING — reconnectMigrate is in flight', async () => {
474
+ const strategies = stubAllStrategies();
475
+ call.state.setCallingState(CallingState.MIGRATING);
476
+
477
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
478
+
479
+ expect(strategies.fast).not.toHaveBeenCalled();
480
+ expect(strategies.rejoin).not.toHaveBeenCalled();
481
+ expect(strategies.migrate).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it('bails immediately when state is RECONNECTING_FAILED — terminal, no further attempts', async () => {
485
+ const strategies = stubAllStrategies();
486
+ call.state.setCallingState(CallingState.RECONNECTING_FAILED);
487
+
488
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
489
+
490
+ expect(strategies.fast).not.toHaveBeenCalled();
491
+ expect(strategies.rejoin).not.toHaveBeenCalled();
492
+ expect(strategies.migrate).not.toHaveBeenCalled();
493
+ });
494
+
495
+ it('drops duplicate reconnect calls while one is already pending', async () => {
496
+ let resolveFirst: () => void = () => {};
497
+ const firstStrategy = new Promise<void>((resolve) => {
498
+ resolveFirst = resolve;
499
+ });
500
+ const rejoinSpy = vi
501
+ .spyOn(
502
+ call as unknown as { reconnectRejoin: () => Promise<void> },
503
+ 'reconnectRejoin',
504
+ )
505
+ .mockImplementationOnce(async () => {
506
+ await firstStrategy;
507
+ call.state.setCallingState(CallingState.JOINED);
508
+ });
509
+
510
+ primeForReconnect(call);
511
+
512
+ const firstCall = call['reconnect'](
513
+ WebsocketReconnectStrategy.REJOIN,
514
+ 'first',
515
+ );
516
+ await Promise.resolve();
517
+ call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'second');
518
+
519
+ expect(rejoinSpy).toHaveBeenCalledTimes(1);
520
+
521
+ resolveFirst();
522
+ await firstCall;
523
+ });
524
+ });
525
+
407
526
  /**
408
527
  * End-to-end-ish wiring tests: simulate failures at the peer-connection layer
409
528
  * and verify they propagate through `onReconnectionNeeded` → `Call.reconnect` →