livekit-client 2.13.7 → 2.14.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/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +13 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +188 -90
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  10. package/dist/src/e2ee/types.d.ts +7 -0
  11. package/dist/src/e2ee/types.d.ts.map +1 -1
  12. package/dist/src/e2ee/worker/FrameCryptor.d.ts +2 -1
  13. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  14. package/dist/src/room/PCTransport.d.ts +1 -0
  15. package/dist/src/room/PCTransport.d.ts.map +1 -1
  16. package/dist/src/room/events.d.ts +18 -0
  17. package/dist/src/room/events.d.ts.map +1 -1
  18. package/dist/src/room/participant/LocalParticipant.d.ts +1 -0
  19. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  20. package/dist/src/room/participant/Participant.d.ts +3 -0
  21. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  22. package/dist/src/room/track/LocalTrackPublication.d.ts +1 -0
  23. package/dist/src/room/track/LocalTrackPublication.d.ts.map +1 -1
  24. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  25. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  26. package/dist/src/room/track/Track.d.ts +1 -0
  27. package/dist/src/room/track/Track.d.ts.map +1 -1
  28. package/dist/src/room/track/TrackPublication.d.ts +1 -0
  29. package/dist/src/room/track/TrackPublication.d.ts.map +1 -1
  30. package/dist/src/room/track/options.d.ts +4 -0
  31. package/dist/src/room/track/options.d.ts.map +1 -1
  32. package/dist/src/room/utils.d.ts +1 -1
  33. package/dist/src/room/utils.d.ts.map +1 -1
  34. package/dist/ts4.2/src/e2ee/types.d.ts +7 -0
  35. package/dist/ts4.2/src/e2ee/worker/FrameCryptor.d.ts +2 -1
  36. package/dist/ts4.2/src/room/PCTransport.d.ts +1 -0
  37. package/dist/ts4.2/src/room/events.d.ts +18 -0
  38. package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +1 -0
  39. package/dist/ts4.2/src/room/participant/Participant.d.ts +3 -0
  40. package/dist/ts4.2/src/room/track/LocalTrackPublication.d.ts +1 -0
  41. package/dist/ts4.2/src/room/track/LocalVideoTrack.d.ts +7 -0
  42. package/dist/ts4.2/src/room/track/Track.d.ts +1 -0
  43. package/dist/ts4.2/src/room/track/TrackPublication.d.ts +1 -0
  44. package/dist/ts4.2/src/room/track/options.d.ts +4 -0
  45. package/dist/ts4.2/src/room/utils.d.ts +1 -1
  46. package/package.json +1 -1
  47. package/src/e2ee/E2eeManager.ts +8 -3
  48. package/src/e2ee/types.ts +8 -0
  49. package/src/e2ee/worker/FrameCryptor.ts +15 -0
  50. package/src/e2ee/worker/e2ee.worker.ts +8 -5
  51. package/src/room/PCTransport.ts +88 -77
  52. package/src/room/events.ts +21 -0
  53. package/src/room/participant/LocalParticipant.ts +14 -2
  54. package/src/room/participant/Participant.ts +3 -0
  55. package/src/room/participant/publishUtils.ts +2 -2
  56. package/src/room/track/LocalTrackPublication.ts +9 -1
  57. package/src/room/track/LocalVideoTrack.ts +68 -1
  58. package/src/room/track/Track.ts +1 -0
  59. package/src/room/track/TrackPublication.ts +1 -0
  60. package/src/room/track/create.ts +2 -2
  61. package/src/room/track/options.ts +5 -0
  62. package/src/room/utils.ts +19 -3
@@ -34,7 +34,7 @@ export declare function isFireFox(): boolean;
34
34
  export declare function isChromiumBased(): boolean;
35
35
  export declare function isSafari(): boolean;
36
36
  export declare function isSafariBased(): boolean;
37
- export declare function isSafari17(): boolean;
37
+ export declare function isSafari17Based(): boolean;
38
38
  export declare function isSafariSvcApi(browser?: BrowserDetails): boolean;
39
39
  export declare function isMobile(): boolean;
40
40
  export declare function isE2EESimulcastSupported(): boolean | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livekit-client",
3
- "version": "2.13.7",
3
+ "version": "2.14.0",
4
4
  "description": "JavaScript/TypeScript client SDK for LiveKit",
5
5
  "main": "./dist/livekit-client.umd.js",
6
6
  "unpkg": "./dist/livekit-client.umd.js",
@@ -25,6 +25,7 @@ import type {
25
25
  RTPVideoMapMessage,
26
26
  RatchetRequestMessage,
27
27
  RemoveTransformMessage,
28
+ ScriptTransformOptions,
28
29
  SetKeyMessage,
29
30
  SifTrailerMessage,
30
31
  UpdateCodecMessage,
@@ -220,8 +221,9 @@ export class E2EEManager
220
221
  this.room.localParticipant.identity,
221
222
  );
222
223
  });
223
- room.localParticipant.on(ParticipantEvent.LocalTrackPublished, async (publication) => {
224
- this.setupE2EESender(publication.track!, publication.track!.sender!);
224
+
225
+ room.localParticipant.on(ParticipantEvent.LocalSenderCreated, async (sender, track) => {
226
+ this.setupE2EESender(track, sender);
225
227
  });
226
228
 
227
229
  keyProvider
@@ -345,7 +347,7 @@ export class E2EEManager
345
347
  }
346
348
 
347
349
  if (isScriptTransformSupported()) {
348
- const options = {
350
+ const options: ScriptTransformOptions = {
349
351
  kind: 'decode',
350
352
  participantIdentity,
351
353
  trackId,
@@ -371,6 +373,7 @@ export class E2EEManager
371
373
  let writable: WritableStream = receiver.writableStream;
372
374
  // @ts-ignore
373
375
  let readable: ReadableStream = receiver.readableStream;
376
+
374
377
  if (!writable || !readable) {
375
378
  // @ts-ignore
376
379
  const receiverStreams = receiver.createEncodedStreams();
@@ -390,6 +393,7 @@ export class E2EEManager
390
393
  trackId: trackId,
391
394
  codec,
392
395
  participantIdentity: participantIdentity,
396
+ isReuse: E2EE_FLAG in receiver,
393
397
  },
394
398
  };
395
399
  this.worker.postMessage(msg, [readable, writable]);
@@ -435,6 +439,7 @@ export class E2EEManager
435
439
  codec,
436
440
  trackId,
437
441
  participantIdentity: this.room.localParticipant.identity,
442
+ isReuse: false,
438
443
  },
439
444
  };
440
445
  this.worker.postMessage(msg, [senderStreams.readable, senderStreams.writable]);
package/src/e2ee/types.ts CHANGED
@@ -49,6 +49,7 @@ export interface EncodeMessage extends BaseMessage {
49
49
  writableStream: WritableStream;
50
50
  trackId: string;
51
51
  codec?: VideoCodec;
52
+ isReuse: boolean;
52
53
  };
53
54
  }
54
55
 
@@ -162,3 +163,10 @@ export type DecodeRatchetOptions = {
162
163
  /** ratcheted key to try */
163
164
  encryptionKey?: CryptoKey;
164
165
  };
166
+
167
+ export type ScriptTransformOptions = {
168
+ kind: 'decode' | 'encode';
169
+ participantIdentity: string;
170
+ trackId: string;
171
+ codec?: VideoCodec;
172
+ };
@@ -69,6 +69,8 @@ export class FrameCryptor extends BaseFrameCryptor {
69
69
 
70
70
  private detectedCodec?: VideoCodec;
71
71
 
72
+ private isTransformActive: boolean = false;
73
+
72
74
  constructor(opts: {
73
75
  keys: ParticipantKeyHandler;
74
76
  participantIdentity: string;
@@ -159,6 +161,7 @@ export class FrameCryptor extends BaseFrameCryptor {
159
161
  readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
160
162
  writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
161
163
  trackId: string,
164
+ isReuse: boolean,
162
165
  codec?: VideoCodec,
163
166
  ) {
164
167
  if (codec) {
@@ -173,11 +176,20 @@ export class FrameCryptor extends BaseFrameCryptor {
173
176
  ...this.logContext,
174
177
  });
175
178
 
179
+ if (isReuse && this.isTransformActive) {
180
+ workerLogger.debug('reuse transform', {
181
+ ...this.logContext,
182
+ });
183
+ return;
184
+ }
185
+
176
186
  const transformFn = operation === 'encode' ? this.encodeFunction : this.decodeFunction;
177
187
  const transformStream = new TransformStream({
178
188
  transform: transformFn.bind(this),
179
189
  });
180
190
 
191
+ this.isTransformActive = true;
192
+
181
193
  readable
182
194
  .pipeThrough(transformStream)
183
195
  .pipeTo(writable)
@@ -189,6 +201,9 @@ export class FrameCryptor extends BaseFrameCryptor {
189
201
  ? e
190
202
  : new CryptorError(e.message, undefined, this.participantIdentity),
191
203
  );
204
+ })
205
+ .finally(() => {
206
+ this.isTransformActive = false;
192
207
  });
193
208
  this.trackId = trackId;
194
209
  }
@@ -12,6 +12,7 @@ import type {
12
12
  RatchetMessage,
13
13
  RatchetRequestMessage,
14
14
  RatchetResult,
15
+ ScriptTransformOptions,
15
16
  } from '../types';
16
17
  import { FrameCryptor, encryptionEnabledMap } from './FrameCryptor';
17
18
  import { ParticipantKeyHandler } from './ParticipantKeyHandler';
@@ -65,6 +66,7 @@ onmessage = (ev) => {
65
66
  data.readableStream,
66
67
  data.writableStream,
67
68
  data.trackId,
69
+ data.isReuse,
68
70
  data.codec,
69
71
  );
70
72
  break;
@@ -75,6 +77,7 @@ onmessage = (ev) => {
75
77
  data.readableStream,
76
78
  data.writableStream,
77
79
  data.trackId,
80
+ data.isReuse,
78
81
  data.codec,
79
82
  );
80
83
  break;
@@ -259,14 +262,14 @@ if (self.RTCTransformEvent) {
259
262
  workerLogger.debug('setup transform event');
260
263
  // @ts-ignore
261
264
  self.onrtctransform = (event: RTCTransformEvent) => {
262
- // @ts-ignore .transformer property is part of RTCTransformEvent
265
+ // @ts-ignore
263
266
  const transformer = event.transformer;
264
267
  workerLogger.debug('transformer', transformer);
265
- // @ts-ignore monkey patching non standard flag
266
- transformer.handled = true;
267
- const { kind, participantIdentity, trackId, codec } = transformer.options;
268
+
269
+ const { kind, participantIdentity, trackId, codec } =
270
+ transformer.options as ScriptTransformOptions;
268
271
  const cryptor = getTrackCryptor(participantIdentity, trackId);
269
272
  workerLogger.debug('transform', { codec });
270
- cryptor.setupTransform(kind, transformer.readable, transformer.writable, trackId, codec);
273
+ cryptor.setupTransform(kind, transformer.readable, transformer.writable, trackId, false, codec);
271
274
  };
272
275
  }
@@ -1,3 +1,4 @@
1
+ import { Mutex } from '@livekit/mutex';
1
2
  import { EventEmitter } from 'events';
2
3
  import type { MediaDescription, SessionDescription } from 'sdp-transform';
3
4
  import { parse, write } from 'sdp-transform';
@@ -52,6 +53,8 @@ export default class PCTransport extends EventEmitter {
52
53
 
53
54
  private latestOfferId: number = 0;
54
55
 
56
+ private offerLock: Mutex;
57
+
55
58
  pendingCandidates: RTCIceCandidateInit[] = [];
56
59
 
57
60
  restartingIce: boolean = false;
@@ -86,6 +89,7 @@ export default class PCTransport extends EventEmitter {
86
89
  this.loggerOptions = loggerOptions;
87
90
  this.config = config;
88
91
  this._pc = this.createPC();
92
+ this.offerLock = new Mutex();
89
93
  }
90
94
 
91
95
  private createPC() {
@@ -251,101 +255,108 @@ export default class PCTransport extends EventEmitter {
251
255
  }, debounceInterval);
252
256
 
253
257
  async createAndSendOffer(options?: RTCOfferOptions) {
254
- // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
255
- const offerId = this.latestOfferId + 1;
256
- this.latestOfferId = offerId;
257
- if (this.onOffer === undefined) {
258
- return;
259
- }
258
+ const unlock = await this.offerLock.lock();
260
259
 
261
- if (options?.iceRestart) {
262
- this.log.debug('restarting ICE', this.logContext);
263
- this.restartingIce = true;
264
- }
260
+ try {
261
+ // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
262
+ const offerId = this.latestOfferId + 1;
263
+ this.latestOfferId = offerId;
265
264
 
266
- if (this._pc && this._pc.signalingState === 'have-local-offer') {
267
- // we're waiting for the peer to accept our offer, so we'll just wait
268
- // the only exception to this is when ICE restart is needed
269
- const currentSD = this._pc.remoteDescription;
270
- if (options?.iceRestart && currentSD) {
271
- // TODO: handle when ICE restart is needed but we don't have a remote description
272
- // the best thing to do is to recreate the peerconnection
273
- await this._pc.setRemoteDescription(currentSD);
274
- } else {
275
- this.renegotiate = true;
265
+ if (this.onOffer === undefined) {
276
266
  return;
277
267
  }
278
- } else if (!this._pc || this._pc.signalingState === 'closed') {
279
- this.log.warn('could not createOffer with closed peer connection', this.logContext);
280
- return;
281
- }
282
268
 
283
- // actually negotiate
284
- this.log.debug('starting to negotiate', this.logContext);
285
- const offer = await this.pc.createOffer(options);
286
- this.log.debug('original offer', { sdp: offer.sdp, ...this.logContext });
269
+ if (options?.iceRestart) {
270
+ this.log.debug('restarting ICE', this.logContext);
271
+ this.restartingIce = true;
272
+ }
287
273
 
288
- const sdpParsed = parse(offer.sdp ?? '');
289
- sdpParsed.media.forEach((media) => {
290
- ensureIPAddrMatchVersion(media);
291
- if (media.type === 'audio') {
292
- ensureAudioNackAndStereo(media, [], []);
293
- } else if (media.type === 'video') {
294
- this.trackBitrates.some((trackbr): boolean => {
295
- if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
296
- return false;
297
- }
274
+ if (this._pc && this._pc.signalingState === 'have-local-offer') {
275
+ // we're waiting for the peer to accept our offer, so we'll just wait
276
+ // the only exception to this is when ICE restart is needed
277
+ const currentSD = this._pc.remoteDescription;
278
+ if (options?.iceRestart && currentSD) {
279
+ // TODO: handle when ICE restart is needed but we don't have a remote description
280
+ // the best thing to do is to recreate the peerconnection
281
+ await this._pc.setRemoteDescription(currentSD);
282
+ } else {
283
+ this.renegotiate = true;
284
+ return;
285
+ }
286
+ } else if (!this._pc || this._pc.signalingState === 'closed') {
287
+ this.log.warn('could not createOffer with closed peer connection', this.logContext);
288
+ return;
289
+ }
298
290
 
299
- let codecPayload = 0;
300
- media.rtp.some((rtp): boolean => {
301
- if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) {
302
- codecPayload = rtp.payload;
303
- return true;
291
+ // actually negotiate
292
+ this.log.debug('starting to negotiate', this.logContext);
293
+ const offer = await this.pc.createOffer(options);
294
+ this.log.debug('original offer', { sdp: offer.sdp, ...this.logContext });
295
+
296
+ const sdpParsed = parse(offer.sdp ?? '');
297
+ sdpParsed.media.forEach((media) => {
298
+ ensureIPAddrMatchVersion(media);
299
+ if (media.type === 'audio') {
300
+ ensureAudioNackAndStereo(media, [], []);
301
+ } else if (media.type === 'video') {
302
+ this.trackBitrates.some((trackbr): boolean => {
303
+ if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
304
+ return false;
304
305
  }
305
- return false;
306
- });
307
306
 
308
- if (codecPayload === 0) {
309
- return true;
310
- }
307
+ let codecPayload = 0;
308
+ media.rtp.some((rtp): boolean => {
309
+ if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) {
310
+ codecPayload = rtp.payload;
311
+ return true;
312
+ }
313
+ return false;
314
+ });
311
315
 
312
- if (isSVCCodec(trackbr.codec)) {
313
- this.ensureVideoDDExtensionForSVC(media, sdpParsed);
314
- }
316
+ if (codecPayload === 0) {
317
+ return true;
318
+ }
315
319
 
316
- // TODO: av1 slow starting issue already fixed in chrome 124, clean this after some versions
317
- // mung sdp for av1 bitrate setting that can't apply by sendEncoding
318
- if (trackbr.codec !== 'av1') {
319
- return true;
320
- }
320
+ if (isSVCCodec(trackbr.codec)) {
321
+ this.ensureVideoDDExtensionForSVC(media, sdpParsed);
322
+ }
323
+
324
+ // TODO: av1 slow starting issue already fixed in chrome 124, clean this after some versions
325
+ // mung sdp for av1 bitrate setting that can't apply by sendEncoding
326
+ if (trackbr.codec !== 'av1') {
327
+ return true;
328
+ }
321
329
 
322
- const startBitrate = Math.round(trackbr.maxbr * startBitrateForSVC);
330
+ const startBitrate = Math.round(trackbr.maxbr * startBitrateForSVC);
323
331
 
324
- for (const fmtp of media.fmtp) {
325
- if (fmtp.payload === codecPayload) {
326
- // if another track's fmtp already is set, we cannot override the bitrate
327
- // this has the unfortunate consequence of being forced to use the
328
- // initial track's bitrate for all tracks
329
- if (!fmtp.config.includes('x-google-start-bitrate')) {
330
- fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
332
+ for (const fmtp of media.fmtp) {
333
+ if (fmtp.payload === codecPayload) {
334
+ // if another track's fmtp already is set, we cannot override the bitrate
335
+ // this has the unfortunate consequence of being forced to use the
336
+ // initial track's bitrate for all tracks
337
+ if (!fmtp.config.includes('x-google-start-bitrate')) {
338
+ fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
339
+ }
340
+ break;
331
341
  }
332
- break;
333
342
  }
334
- }
335
- return true;
343
+ return true;
344
+ });
345
+ }
346
+ });
347
+ if (this.latestOfferId > offerId) {
348
+ this.log.warn('latestOfferId mismatch', {
349
+ ...this.logContext,
350
+ latestOfferId: this.latestOfferId,
351
+ offerId,
336
352
  });
353
+ return;
337
354
  }
338
- });
339
- if (this.latestOfferId > offerId) {
340
- this.log.warn('latestOfferId mismatch', {
341
- ...this.logContext,
342
- latestOfferId: this.latestOfferId,
343
- offerId,
344
- });
345
- return;
355
+ await this.setMungedSDP(offer, write(sdpParsed));
356
+ this.onOffer(offer, this.latestOfferId);
357
+ } finally {
358
+ unlock();
346
359
  }
347
- await this.setMungedSDP(offer, write(sdpParsed));
348
- this.onOffer(offer, this.latestOfferId);
349
360
  }
350
361
 
351
362
  async createAndSetAnswer(): Promise<RTCSessionDescriptionInit> {
@@ -429,6 +429,21 @@ export enum ParticipantEvent {
429
429
  */
430
430
  LocalTrackUnpublished = 'localTrackUnpublished',
431
431
 
432
+ /**
433
+ * A local track has been constrained by cpu.
434
+ * This event is useful to know when to reduce the capture resolution of the track.
435
+ *
436
+ * This event is emitted on the local participant.
437
+ *
438
+ * args: ([[LocalVideoTrack]], [[LocalTrackPublication]])
439
+ */
440
+ LocalTrackCpuConstrained = 'localTrackCpuConstrained',
441
+
442
+ /**
443
+ * @internal
444
+ */
445
+ LocalSenderCreated = 'localSenderCreated',
446
+
432
447
  /**
433
448
  * Participant metadata is a simple way for app-specific state to be pushed to
434
449
  * all users.
@@ -515,6 +530,11 @@ export enum ParticipantEvent {
515
530
  */
516
531
  TrackSubscriptionStatusChanged = 'trackSubscriptionStatusChanged',
517
532
 
533
+ /**
534
+ * a local track has been constrained by cpu
535
+ */
536
+ TrackCpuConstrained = 'trackCpuConstrained',
537
+
518
538
  // fired only on LocalParticipant
519
539
  /** @internal */
520
540
  MediaDevicesError = 'mediaDevicesError',
@@ -599,6 +619,7 @@ export enum TrackEvent {
599
619
  Ended = 'ended',
600
620
  Subscribed = 'subscribed',
601
621
  Unsubscribed = 'unsubscribed',
622
+ CpuConstrained = 'cpuConstrained',
602
623
  /** @internal */
603
624
  UpdateSettings = 'updateSettings',
604
625
  /** @internal */
@@ -93,7 +93,7 @@ import {
93
93
  isLocalTrack,
94
94
  isLocalVideoTrack,
95
95
  isSVCCodec,
96
- isSafari17,
96
+ isSafari17Based,
97
97
  isVideoTrack,
98
98
  isWeb,
99
99
  numberToBigInt,
@@ -708,7 +708,7 @@ export default class LocalParticipant extends Participant {
708
708
  throw new DeviceUnsupportedError('getDisplayMedia not supported');
709
709
  }
710
710
 
711
- if (options.resolution === undefined && !isSafari17()) {
711
+ if (options.resolution === undefined && !isSafari17Based()) {
712
712
  // we need to constrain the dimensions, otherwise it could lead to low bitrate
713
713
  // due to encoding a huge video. Encoding such large surfaces is really expensive
714
714
  // unfortunately Safari 17 has a but and cannot be constrained by default
@@ -1169,6 +1169,7 @@ export default class LocalParticipant extends Participant {
1169
1169
  }
1170
1170
 
1171
1171
  track.sender = await this.engine.createSender(track, opts, encodings);
1172
+ this.emit(ParticipantEvent.LocalSenderCreated, track.sender, track);
1172
1173
 
1173
1174
  if (isLocalVideoTrack(track)) {
1174
1175
  opts.degradationPreference ??= getDefaultDegradationPreference(track);
@@ -1271,6 +1272,9 @@ export default class LocalParticipant extends Participant {
1271
1272
  loggerName: this.roomOptions.loggerName,
1272
1273
  loggerContextCb: () => this.logContext,
1273
1274
  });
1275
+ publication.on(TrackEvent.CpuConstrained, (constrainedTrack) =>
1276
+ this.onTrackCpuConstrained(constrainedTrack, publication),
1277
+ );
1274
1278
  // save options for when it needs to be republished again
1275
1279
  publication.options = opts;
1276
1280
  track.sid = ti.sid;
@@ -2329,6 +2333,14 @@ export default class LocalParticipant extends Participant {
2329
2333
  this.engine.client.sendUpdateLocalAudioTrack(pub.trackSid, pub.getTrackFeatures());
2330
2334
  };
2331
2335
 
2336
+ private onTrackCpuConstrained = (track: LocalVideoTrack, publication: LocalTrackPublication) => {
2337
+ this.log.debug('track cpu constrained', {
2338
+ ...this.logContext,
2339
+ ...getLogContextFromTrack(publication),
2340
+ });
2341
+ this.emit(ParticipantEvent.LocalTrackCpuConstrained, track, publication);
2342
+ };
2343
+
2332
2344
  private handleSubscribedQualityUpdate = async (update: SubscribedQualityUpdate) => {
2333
2345
  if (!this.roomOptions?.dynacast) {
2334
2346
  return;
@@ -13,6 +13,7 @@ import type TypedEmitter from 'typed-emitter';
13
13
  import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
14
14
  import { ParticipantEvent, TrackEvent } from '../events';
15
15
  import type LocalTrackPublication from '../track/LocalTrackPublication';
16
+ import type LocalVideoTrack from '../track/LocalVideoTrack';
16
17
  import type RemoteTrack from '../track/RemoteTrack';
17
18
  import type RemoteTrackPublication from '../track/RemoteTrackPublication';
18
19
  import { Track } from '../track/Track';
@@ -403,6 +404,8 @@ export type ParticipantEventCallbacks = {
403
404
  trackUnmuted: (publication: TrackPublication) => void;
404
405
  localTrackPublished: (publication: LocalTrackPublication) => void;
405
406
  localTrackUnpublished: (publication: LocalTrackPublication) => void;
407
+ localTrackCpuConstrained: (track: LocalVideoTrack, publication: LocalTrackPublication) => void;
408
+ localSenderCreated: (sender: RTCRtpSender, track: Track) => void;
406
409
  participantMetadataChanged: (prevMetadata: string | undefined, participant?: any) => void;
407
410
  participantNameChanged: (name: string) => void;
408
411
  dataReceived: (payload: Uint8Array, kind: DataPacket_Kind) => void;
@@ -18,7 +18,7 @@ import {
18
18
  isFireFox,
19
19
  isReactNative,
20
20
  isSVCCodec,
21
- isSafari,
21
+ isSafariBased,
22
22
  isSafariSvcApi,
23
23
  unwrapConstraint,
24
24
  } from '../utils';
@@ -151,7 +151,7 @@ export function computeVideoEncodings(
151
151
  // Announced here: https://groups.google.com/g/discuss-webrtc/c/-QQ3pxrl-fw?pli=1
152
152
  const browser = getBrowser();
153
153
  if (
154
- isSafari() ||
154
+ isSafariBased() ||
155
155
  // Even tho RN runs M114, it does not produce SVC layers when a single encoding
156
156
  // is provided. So we'll use the legacy SVC specification for now.
157
157
  // TODO: when we upstream libwebrtc, this will need additional verification
@@ -1,7 +1,7 @@
1
1
  import { AudioTrackFeature, TrackInfo } from '@livekit/protocol';
2
2
  import { TrackEvent } from '../events';
3
3
  import type { LoggerOptions } from '../types';
4
- import { isAudioTrack } from '../utils';
4
+ import { isAudioTrack, isVideoTrack } from '../utils';
5
5
  import LocalAudioTrack from './LocalAudioTrack';
6
6
  import type LocalTrack from './LocalTrack';
7
7
  import type LocalVideoTrack from './LocalVideoTrack';
@@ -28,12 +28,14 @@ export default class LocalTrackPublication extends TrackPublication {
28
28
  setTrack(track?: Track) {
29
29
  if (this.track) {
30
30
  this.track.off(TrackEvent.Ended, this.handleTrackEnded);
31
+ this.track.off(TrackEvent.CpuConstrained, this.handleCpuConstrained);
31
32
  }
32
33
 
33
34
  super.setTrack(track);
34
35
 
35
36
  if (track) {
36
37
  track.on(TrackEvent.Ended, this.handleTrackEnded);
38
+ track.on(TrackEvent.CpuConstrained, this.handleCpuConstrained);
37
39
  }
38
40
  }
39
41
 
@@ -116,4 +118,10 @@ export default class LocalTrackPublication extends TrackPublication {
116
118
  handleTrackEnded = () => {
117
119
  this.emit(TrackEvent.Ended);
118
120
  };
121
+
122
+ private handleCpuConstrained = () => {
123
+ if (this.track && isVideoTrack(this.track)) {
124
+ this.emit(TrackEvent.CpuConstrained, this.track);
125
+ }
126
+ };
119
127
  }
@@ -7,6 +7,7 @@ import {
7
7
  } from '@livekit/protocol';
8
8
  import type { SignalClient } from '../../api/SignalClient';
9
9
  import type { StructuredLogger } from '../../logger';
10
+ import { TrackEvent } from '../events';
10
11
  import { ScalabilityMode } from '../participant/publishUtils';
11
12
  import type { VideoSenderStats } from '../stats';
12
13
  import { computeBitrate, monitorFrequency } from '../stats';
@@ -56,6 +57,10 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
56
57
 
57
58
  private degradationPreference: RTCDegradationPreference = 'balanced';
58
59
 
60
+ private isCpuConstrained: boolean = false;
61
+
62
+ private optimizeForPerformance: boolean = false;
63
+
59
64
  get sender(): RTCRtpSender | undefined {
60
65
  return this._sender;
61
66
  }
@@ -251,6 +256,9 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
251
256
  }
252
257
  await this.restart(constraints);
253
258
 
259
+ // reset cpu constrained state after track is restarted
260
+ this.isCpuConstrained = false;
261
+
254
262
  for await (const sc of this.simulcastCodecs.values()) {
255
263
  if (sc.sender && sc.sender.transport?.state !== 'closed') {
256
264
  sc.mediaStreamTrack = this.mediaStreamTrack.clone();
@@ -334,6 +342,7 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
334
342
  // only enable simulcast codec for preference codec setted
335
343
  if (!this.codec && codecs.length > 0) {
336
344
  await this.setPublishingLayers(isSVCCodec(codecs[0].codec), codecs[0].qualities);
345
+
337
346
  return [];
338
347
  }
339
348
 
@@ -378,6 +387,13 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
378
387
  * Sets layers that should be publishing
379
388
  */
380
389
  async setPublishingLayers(isSvc: boolean, qualities: SubscribedQuality[]) {
390
+ if (this.optimizeForPerformance) {
391
+ this.log.info('skipping setPublishingLayers due to optimized publishing performance', {
392
+ ...this.logContext,
393
+ qualities,
394
+ });
395
+ return;
396
+ }
381
397
  this.log.debug('setting publishing layers', { ...this.logContext, qualities });
382
398
  if (!this.sender || !this.encodings) {
383
399
  return;
@@ -394,6 +410,49 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
394
410
  );
395
411
  }
396
412
 
413
+ /**
414
+ * Designed for lower powered devices, reduces video publishing quality and disables simulcast.
415
+ * @experimental
416
+ */
417
+ async prioritizePerformance() {
418
+ if (!this.sender) {
419
+ throw new Error('sender not found');
420
+ }
421
+
422
+ const unlock = await this.senderLock.lock();
423
+
424
+ try {
425
+ this.optimizeForPerformance = true;
426
+ const params = this.sender.getParameters();
427
+
428
+ params.encodings = params.encodings.map((e, idx) => ({
429
+ ...e,
430
+ active: idx === 0,
431
+ scaleResolutionDownBy: Math.max(
432
+ 1,
433
+ Math.ceil((this.mediaStreamTrack.getSettings().height ?? 360) / 360),
434
+ ),
435
+ scalabilityMode: idx === 0 && isSVCCodec(this.codec) ? 'L1T3' : undefined,
436
+ maxFramerate: idx === 0 ? 15 : 0,
437
+ maxBitrate: idx === 0 ? e.maxBitrate : 0,
438
+ }));
439
+ this.log.debug('setting performance optimised encodings', {
440
+ ...this.logContext,
441
+ encodings: params.encodings,
442
+ });
443
+ this.encodings = params.encodings;
444
+ await this.sender.setParameters(params);
445
+ } catch (e) {
446
+ this.log.error('failed to set performance optimised encodings', {
447
+ ...this.logContext,
448
+ error: e,
449
+ });
450
+ this.optimizeForPerformance = false;
451
+ } finally {
452
+ unlock();
453
+ }
454
+ }
455
+
397
456
  protected monitorSender = async () => {
398
457
  if (!this.sender) {
399
458
  this._currentBitrate = 0;
@@ -404,11 +463,19 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
404
463
  try {
405
464
  stats = await this.getSenderStats();
406
465
  } catch (e) {
407
- this.log.error('could not get audio sender stats', { ...this.logContext, error: e });
466
+ this.log.error('could not get video sender stats', { ...this.logContext, error: e });
408
467
  return;
409
468
  }
410
469
  const statsMap = new Map<string, VideoSenderStats>(stats.map((s) => [s.rid, s]));
411
470
 
471
+ const isCpuConstrained = stats.some((s) => s.qualityLimitationReason === 'cpu');
472
+ if (isCpuConstrained !== this.isCpuConstrained) {
473
+ this.isCpuConstrained = isCpuConstrained;
474
+ if (this.isCpuConstrained) {
475
+ this.emit(TrackEvent.CpuConstrained);
476
+ }
477
+ }
478
+
412
479
  if (this.prevStats) {
413
480
  let totalBitrate = 0;
414
481
  statsMap.forEach((s, key) => {