@stream-io/video-client 1.22.0 → 1.22.1

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.
@@ -285,6 +285,14 @@ export interface PublishOption {
285
285
  * @generated from protobuf field: int32 id = 8;
286
286
  */
287
287
  id: number;
288
+ /**
289
+ * If true, instructs the publisher to send only the highest available simulcast layer,
290
+ * disabling all lower layers. This applies to simulcast encodings.
291
+ * For SVC codecs, prefer using the L1T3 (single spatial, 3 temporal layers) mode instead.
292
+ *
293
+ * @generated from protobuf field: bool use_single_layer = 9;
294
+ */
295
+ useSingleLayer: boolean;
288
296
  }
289
297
  /**
290
298
  * @generated from protobuf message stream.video.sfu.models.Codec
@@ -43,5 +43,6 @@ export declare class SfuStatsReporter {
43
43
  private run;
44
44
  start: () => void;
45
45
  stop: () => void;
46
+ flush: () => void;
46
47
  scheduleOne: (timeout: number) => void;
47
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.22.0",
3
+ "version": "1.22.1",
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
@@ -591,6 +591,7 @@ export class Call {
591
591
  this.statsReporter?.stop();
592
592
  this.statsReporter = undefined;
593
593
 
594
+ this.sfuStatsReporter?.flush();
594
595
  this.sfuStatsReporter?.stop();
595
596
  this.sfuStatsReporter = undefined;
596
597
 
@@ -1013,7 +1014,7 @@ export class Call {
1013
1014
  );
1014
1015
  }
1015
1016
 
1016
- if (performingRejoin) {
1017
+ if (performingRejoin && isWsHealthy) {
1017
1018
  const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
1018
1019
  await previousSfuClient?.leaveAndClose(
1019
1020
  `Closing previous WS after reconnect with strategy: ${strategy}`,
@@ -1338,16 +1339,12 @@ export class Call {
1338
1339
  ): Promise<void> => {
1339
1340
  if (
1340
1341
  this.state.callingState === CallingState.RECONNECTING ||
1342
+ this.state.callingState === CallingState.MIGRATING ||
1341
1343
  this.state.callingState === CallingState.RECONNECTING_FAILED
1342
1344
  )
1343
1345
  return;
1344
1346
 
1345
1347
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1346
- this.logger(
1347
- 'info',
1348
- `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`,
1349
- );
1350
-
1351
1348
  const reconnectStartTime = Date.now();
1352
1349
  this.reconnectStrategy = strategy;
1353
1350
  this.reconnectReason = reason;
@@ -1374,6 +1371,12 @@ export class Call {
1374
1371
  try {
1375
1372
  // wait until the network is available
1376
1373
  await this.networkAvailableTask?.promise;
1374
+
1375
+ this.logger(
1376
+ 'info',
1377
+ `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`,
1378
+ );
1379
+
1377
1380
  switch (this.reconnectStrategy) {
1378
1381
  case WebsocketReconnectStrategy.UNSPECIFIED:
1379
1382
  case WebsocketReconnectStrategy.DISCONNECT:
@@ -1428,6 +1431,7 @@ export class Call {
1428
1431
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
1429
1432
  this.state.callingState !== CallingState.LEFT
1430
1433
  );
1434
+ this.logger('info', '[Reconnect] Reconnection flow finished');
1431
1435
  });
1432
1436
  };
1433
1437
 
@@ -289,6 +289,14 @@ export interface PublishOption {
289
289
  * @generated from protobuf field: int32 id = 8;
290
290
  */
291
291
  id: number;
292
+ /**
293
+ * If true, instructs the publisher to send only the highest available simulcast layer,
294
+ * disabling all lower layers. This applies to simulcast encodings.
295
+ * For SVC codecs, prefer using the L1T3 (single spatial, 3 temporal layers) mode instead.
296
+ *
297
+ * @generated from protobuf field: bool use_single_layer = 9;
298
+ */
299
+ useSingleLayer: boolean;
292
300
  }
293
301
  /**
294
302
  * @generated from protobuf message stream.video.sfu.models.Codec
@@ -1314,6 +1322,12 @@ class PublishOption$Type extends MessageType<PublishOption> {
1314
1322
  T: () => VideoDimension,
1315
1323
  },
1316
1324
  { no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
1325
+ {
1326
+ no: 9,
1327
+ name: 'use_single_layer',
1328
+ kind: 'scalar',
1329
+ T: 8 /*ScalarType.BOOL*/,
1330
+ },
1317
1331
  ]);
1318
1332
  }
1319
1333
  }
@@ -258,10 +258,11 @@ export abstract class BasePeerConnection {
258
258
  // do nothing when ICE is restarting
259
259
  if (this.isIceRestarting) return;
260
260
 
261
- if (state === 'failed' || state === 'disconnected') {
261
+ if (state === 'failed') {
262
+ this.onUnrecoverableError?.('ICE connection failed');
263
+ } else if (state === 'disconnected') {
262
264
  this.logger('debug', `Attempting to restart ICE`);
263
265
  this.restartIce().catch((e) => {
264
- if (this.isDisposed) return;
265
266
  const reason = `ICE restart failed`;
266
267
  this.logger('error', reason, e);
267
268
  this.onUnrecoverableError?.(`${reason}: ${e}`);
@@ -122,6 +122,10 @@ export class Publisher extends BasePeerConnection {
122
122
  sendEncodings,
123
123
  });
124
124
 
125
+ const params = transceiver.sender.getParameters();
126
+ params.degradationPreference = 'maintain-framerate';
127
+ await transceiver.sender.setParameters(params);
128
+
125
129
  const trackType = publishOption.trackType;
126
130
  this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
127
131
  this.transceiverCache.add(publishOption, transceiver);
@@ -243,11 +243,11 @@ describe('Publisher', () => {
243
243
  });
244
244
 
245
245
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
246
- vi.spyOn(publisher, 'restartIce').mockResolvedValue();
246
+ publisher['onUnrecoverableError'] = vi.fn();
247
247
  // @ts-expect-error private api
248
248
  publisher['pc'].iceConnectionState = 'failed';
249
249
  publisher['onIceConnectionStateChange']();
250
- expect(publisher.restartIce).toHaveBeenCalled();
250
+ expect(publisher['onUnrecoverableError']).toHaveBeenCalled();
251
251
  });
252
252
 
253
253
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
@@ -103,11 +103,11 @@ describe('Subscriber', () => {
103
103
  });
104
104
 
105
105
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
106
- vi.spyOn(subscriber, 'restartIce').mockResolvedValue();
106
+ subscriber['onUnrecoverableError'] = vi.fn();
107
107
  // @ts-expect-error - private field
108
108
  subscriber['pc'].iceConnectionState = 'failed';
109
109
  subscriber['onIceConnectionStateChange']();
110
- expect(subscriber.restartIce).toHaveBeenCalled();
110
+ expect(subscriber['onUnrecoverableError']).toHaveBeenCalled();
111
111
  });
112
112
 
113
113
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
@@ -55,7 +55,7 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
55
55
  sender: {
56
56
  track: null,
57
57
  replaceTrack: vi.fn(),
58
- getParameters: vi.fn(),
58
+ getParameters: vi.fn().mockReturnValue({}),
59
59
  setParameters: vi.fn(),
60
60
  },
61
61
  setCodecPreferences: vi.fn(),
@@ -170,6 +170,50 @@ describe('videoLayers', () => {
170
170
  expect(layers[2].rid).toBe('f');
171
171
  });
172
172
 
173
+ it('should activate only a single layer when useSingleLayer is true', () => {
174
+ const track = new MediaStreamTrack();
175
+ // @ts-expect-error - incomplete data
176
+ const layers = computeVideoLayers(track, { useSingleLayer: true });
177
+ expect(layers.length).toBe(3);
178
+ expect(layers[0].active).toBe(false);
179
+ expect(layers[0].rid).toBe('q');
180
+ expect(layers[1].active).toBe(false);
181
+ expect(layers[1].rid).toBe('h');
182
+ expect(layers[2].active).toBe(true);
183
+ expect(layers[2].rid).toBe('f');
184
+ });
185
+
186
+ it('should activate only a single layer when useSingleLayer is true in single layer mode', () => {
187
+ const track = new MediaStreamTrack();
188
+ vi.spyOn(track, 'getSettings').mockReturnValue({ width: 320, height: 180 });
189
+ // @ts-expect-error - incomplete data
190
+ const layers = computeVideoLayers(track, { useSingleLayer: true });
191
+ expect(layers.length).toBe(1);
192
+ expect(layers[0].active).toBe(true);
193
+ expect(layers[0].rid).toBe('q');
194
+ });
195
+
196
+ it('should activate only one temporal layer when useSingleLayer is true for SVC', () => {
197
+ const track = new MediaStreamTrack();
198
+ const layers = computeVideoLayers(track, {
199
+ // @ts-expect-error - incomplete data
200
+ codec: { name: 'vp9' },
201
+ maxSpatialLayers: 3,
202
+ maxTemporalLayers: 3,
203
+ useSingleLayer: true,
204
+ });
205
+ expect(layers.length).toBe(3);
206
+ expect(layers[0].rid).toBe('q');
207
+ expect(layers[0].active).toBe(false);
208
+ expect(layers[0].scalabilityMode).toBe('L1T3');
209
+ expect(layers[1].active).toBe(false);
210
+ expect(layers[1].rid).toBe('h');
211
+ expect(layers[1].scalabilityMode).toBe('L1T3');
212
+ expect(layers[2].active).toBe(true);
213
+ expect(layers[2].rid).toBe('f');
214
+ expect(layers[2].scalabilityMode).toBe('L1T3');
215
+ });
216
+
173
217
  it('should map rids to VideoQuality', () => {
174
218
  expect(ridToVideoQuality('q')).toBe(VideoQuality.LOW_UNSPECIFIED);
175
219
  expect(ridToVideoQuality('h')).toBe(VideoQuality.MID);
@@ -93,6 +93,7 @@ export const computeVideoLayers = (
93
93
  maxSpatialLayers = 3,
94
94
  maxTemporalLayers = 3,
95
95
  videoDimension = { width: 1280, height: 720 },
96
+ useSingleLayer,
96
97
  } = publishOption;
97
98
  const maxBitrate = getComputedMaxBitrate(
98
99
  videoDimension,
@@ -117,7 +118,7 @@ export const computeVideoLayers = (
117
118
  // for SVC codecs, we need to set the scalability mode, and the
118
119
  // codec will handle the rest (layers, temporal layers, etc.)
119
120
  layer.scalabilityMode = toScalabilityMode(
120
- maxSpatialLayers,
121
+ useSingleLayer ? 1 : maxSpatialLayers,
121
122
  maxTemporalLayers,
122
123
  );
123
124
  } else {
@@ -136,7 +137,7 @@ export const computeVideoLayers = (
136
137
 
137
138
  // for simplicity, we start with all layers enabled, then this function
138
139
  // will clear/reassign the layers that are not needed
139
- return withSimulcastConstraints(settings, optimalVideoLayers);
140
+ return withSimulcastConstraints(settings, optimalVideoLayers, useSingleLayer);
140
141
  };
141
142
 
142
143
  /**
@@ -180,6 +181,7 @@ export const getComputedMaxBitrate = (
180
181
  const withSimulcastConstraints = (
181
182
  settings: MediaTrackSettings,
182
183
  optimalVideoLayers: OptimalVideoLayer[],
184
+ useSingleLayer: boolean,
183
185
  ) => {
184
186
  let layers: OptimalVideoLayer[];
185
187
 
@@ -196,8 +198,9 @@ const withSimulcastConstraints = (
196
198
  }
197
199
 
198
200
  const ridMapping = ['q', 'h', 'f'];
199
- return layers.map<OptimalVideoLayer>((layer, index) => ({
201
+ return layers.map<OptimalVideoLayer>((layer, index, arr) => ({
200
202
  ...layer,
201
203
  rid: ridMapping[index], // reassign rid
204
+ active: useSingleLayer && index < arr.length - 1 ? false : layer.active,
202
205
  }));
203
206
  };
@@ -235,6 +235,12 @@ export class SfuStatsReporter {
235
235
  this.timeoutId = undefined;
236
236
  };
237
237
 
238
+ flush = () => {
239
+ this.run().catch((err) => {
240
+ this.logger('warn', 'Failed to flush report stats', err);
241
+ });
242
+ };
243
+
238
244
  scheduleOne = (timeout: number) => {
239
245
  clearTimeout(this.timeoutId);
240
246
  this.timeoutId = setTimeout(() => {