livekit-client 1.2.0 → 1.2.3

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 (36) hide show
  1. package/dist/livekit-client.esm.mjs +1007 -107
  2. package/dist/livekit-client.esm.mjs.map +1 -1
  3. package/dist/livekit-client.umd.js +1 -1
  4. package/dist/livekit-client.umd.js.map +1 -1
  5. package/dist/src/index.d.ts +2 -1
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/options.d.ts +5 -0
  8. package/dist/src/options.d.ts.map +1 -1
  9. package/dist/src/room/DefaultReconnectPolicy.d.ts +8 -0
  10. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -0
  11. package/dist/src/room/PCTransport.d.ts +10 -0
  12. package/dist/src/room/PCTransport.d.ts.map +1 -1
  13. package/dist/src/room/RTCEngine.d.ts +6 -1
  14. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  15. package/dist/src/room/ReconnectPolicy.d.ts +23 -0
  16. package/dist/src/room/ReconnectPolicy.d.ts.map +1 -0
  17. package/dist/src/room/Room.d.ts +8 -0
  18. package/dist/src/room/Room.d.ts.map +1 -1
  19. package/dist/src/room/events.d.ts +2 -2
  20. package/dist/src/room/participant/LocalParticipant.d.ts +3 -3
  21. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  22. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  23. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  24. package/package.json +3 -1
  25. package/src/index.ts +2 -0
  26. package/src/options.ts +6 -0
  27. package/src/room/DefaultReconnectPolicy.ts +35 -0
  28. package/src/room/PCTransport.ts +113 -0
  29. package/src/room/RTCEngine.ts +79 -31
  30. package/src/room/ReconnectPolicy.ts +25 -0
  31. package/src/room/Room.ts +55 -30
  32. package/src/room/events.ts +2 -2
  33. package/src/room/participant/LocalParticipant.ts +56 -20
  34. package/src/room/participant/RemoteParticipant.ts +13 -0
  35. package/src/room/participant/publishUtils.ts +1 -1
  36. package/src/room/track/LocalVideoTrack.ts +0 -1
@@ -2,6 +2,7 @@ import { EventEmitter } from 'events';
2
2
  import type TypedEventEmitter from 'typed-emitter';
3
3
  import { SignalClient, SignalOptions } from '../api/SignalClient';
4
4
  import log from '../logger';
5
+ import { RoomOptions } from '../options';
5
6
  import {
6
7
  ClientConfigSetting,
7
8
  ClientConfiguration,
@@ -19,16 +20,16 @@ import {
19
20
  SignalTarget,
20
21
  TrackPublishedResponse,
21
22
  } from '../proto/livekit_rtc';
23
+ import DefaultReconnectPolicy from './DefaultReconnectPolicy';
22
24
  import { ConnectionError, TrackInvalidError, UnexpectedConnectionState } from './errors';
23
25
  import { EngineEvent } from './events';
24
26
  import PCTransport from './PCTransport';
27
+ import { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
25
28
  import { isFireFox, isWeb, sleep } from './utils';
26
29
 
27
30
  const lossyDataChannel = '_lossy';
28
31
  const reliableDataChannel = '_reliable';
29
- const maxReconnectRetries = 10;
30
32
  const minReconnectWait = 2 * 1000;
31
- const maxReconnectDuration = 60 * 1000;
32
33
  export const maxICEConnectTimeout = 15 * 1000;
33
34
 
34
35
  enum PCState {
@@ -96,9 +97,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
96
97
 
97
98
  private attemptingReconnect: boolean = false;
98
99
 
99
- constructor() {
100
+ private reconnectPolicy: ReconnectPolicy;
101
+
102
+ private reconnectTimeout?: ReturnType<typeof setTimeout>;
103
+
104
+ constructor(private options: RoomOptions) {
100
105
  super();
101
106
  this.client = new SignalClient();
107
+ this.client.signalLatency = this.options.expSignalLatency;
108
+ this.reconnectPolicy = this.options.reconnectPolicy ?? new DefaultReconnectPolicy();
102
109
  }
103
110
 
104
111
  async join(
@@ -157,8 +164,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
157
164
  if (this.pendingTrackResolvers[req.cid]) {
158
165
  throw new TrackInvalidError('a track with the same ID has already been published');
159
166
  }
160
- return new Promise<TrackInfo>((resolve) => {
161
- this.pendingTrackResolvers[req.cid] = resolve;
167
+ return new Promise<TrackInfo>((resolve, reject) => {
168
+ const publicationTimeout = setTimeout(() => {
169
+ reject(
170
+ new ConnectionError('publication of local track timed out, no response from server'),
171
+ );
172
+ }, 15_000);
173
+ this.pendingTrackResolvers[req.cid] = (info: TrackInfo) => {
174
+ clearTimeout(publicationTimeout);
175
+ resolve(info);
176
+ };
162
177
  this.client.sendAddTrack(req);
163
178
  });
164
179
  }
@@ -317,8 +332,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
317
332
  await this.subscriber.setRemoteDescription(sd);
318
333
 
319
334
  // answer the offer
320
- const answer = await this.subscriber.pc.createAnswer();
321
- await this.subscriber.pc.setLocalDescription(answer);
335
+ const answer = await this.subscriber.createAndSetAnswer();
322
336
  this.client.sendAnswer(answer);
323
337
  };
324
338
 
@@ -437,18 +451,42 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
437
451
  // websocket reconnect behavior. if websocket is interrupted, and the PeerConnection
438
452
  // continues to work, we can reconnect to websocket to continue the session
439
453
  // after a number of retries, we'll close and give up permanently
440
- private handleDisconnect = (connection: string) => {
454
+ private handleDisconnect = (connection: string, signalEvents: boolean = false) => {
441
455
  if (this._isClosed) {
442
456
  return;
443
457
  }
458
+
444
459
  log.debug(`${connection} disconnected`);
445
460
  if (this.reconnectAttempts === 0) {
446
461
  // only reset start time on the first try
447
462
  this.reconnectStart = Date.now();
448
463
  }
449
464
 
450
- const delay = this.reconnectAttempts * this.reconnectAttempts * 300;
451
- setTimeout(async () => {
465
+ const disconnect = (duration: number) => {
466
+ log.info(
467
+ `could not recover connection after ${this.reconnectAttempts} attempts, ${duration}ms. giving up`,
468
+ );
469
+ this.emit(EngineEvent.Disconnected);
470
+ this.close();
471
+ };
472
+
473
+ const duration = Date.now() - this.reconnectStart;
474
+ const delay = this.getNextRetryDelay({
475
+ elapsedMs: duration,
476
+ retryCount: this.reconnectAttempts,
477
+ });
478
+
479
+ if (delay === null) {
480
+ disconnect(duration);
481
+ return;
482
+ }
483
+
484
+ log.debug(`reconnecting in ${delay}ms`);
485
+
486
+ if (this.reconnectTimeout) {
487
+ clearTimeout(this.reconnectTimeout);
488
+ }
489
+ this.reconnectTimeout = setTimeout(async () => {
452
490
  if (this._isClosed) {
453
491
  return;
454
492
  }
@@ -469,16 +507,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
469
507
  try {
470
508
  this.attemptingReconnect = true;
471
509
  if (this.fullReconnectOnNext) {
472
- await this.restartConnection();
510
+ await this.restartConnection(signalEvents);
473
511
  } else {
474
- await this.resumeConnection();
512
+ await this.resumeConnection(signalEvents);
475
513
  }
476
514
  this.reconnectAttempts = 0;
477
515
  this.fullReconnectOnNext = false;
516
+ if (this.reconnectTimeout) {
517
+ clearTimeout(this.reconnectTimeout);
518
+ }
478
519
  } catch (e) {
479
520
  this.reconnectAttempts += 1;
480
521
  let reconnectRequired = false;
481
522
  let recoverable = true;
523
+ let requireSignalEvents = false;
482
524
  if (e instanceof UnexpectedConnectionState) {
483
525
  log.debug('received unrecoverable error', { error: e });
484
526
  // unrecoverable
@@ -488,26 +530,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
488
530
  reconnectRequired = true;
489
531
  }
490
532
 
491
- // when we flip from resume to reconnect, we need to reset reconnectAttempts
492
- // this is needed to fire the right reconnecting events
533
+ // when we flip from resume to reconnect
534
+ // we need to fire the right reconnecting events
493
535
  if (reconnectRequired && !this.fullReconnectOnNext) {
494
536
  this.fullReconnectOnNext = true;
495
- this.reconnectAttempts = 0;
496
- }
497
-
498
- const duration = Date.now() - this.reconnectStart;
499
- if (this.reconnectAttempts >= maxReconnectRetries || duration > maxReconnectDuration) {
500
- recoverable = false;
537
+ requireSignalEvents = true;
501
538
  }
502
539
 
503
540
  if (recoverable) {
504
- this.handleDisconnect('reconnect');
541
+ this.handleDisconnect('reconnect', requireSignalEvents);
505
542
  } else {
506
- log.info(
507
- `could not recover connection after ${maxReconnectRetries} attempts, ${duration}ms. giving up`,
508
- );
509
- this.emit(EngineEvent.Disconnected);
510
- this.close();
543
+ disconnect(Date.now() - this.reconnectStart);
511
544
  }
512
545
  } finally {
513
546
  this.attemptingReconnect = false;
@@ -515,14 +548,25 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
515
548
  }, delay);
516
549
  };
517
550
 
518
- private async restartConnection() {
551
+ private getNextRetryDelay(context: ReconnectContext) {
552
+ try {
553
+ return this.reconnectPolicy.nextRetryDelayInMs(context);
554
+ } catch (e) {
555
+ log.warn('encountered error in reconnect policy', { error: e });
556
+ }
557
+
558
+ // error in user code with provided reconnect policy, stop reconnecting
559
+ return null;
560
+ }
561
+
562
+ private async restartConnection(emitRestarting: boolean = false) {
519
563
  if (!this.url || !this.token) {
520
564
  // permanent failure, don't attempt reconnection
521
565
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
522
566
  }
523
567
 
524
568
  log.info(`reconnecting, attempt: ${this.reconnectAttempts}`);
525
- if (this.reconnectAttempts === 0) {
569
+ if (emitRestarting || this.reconnectAttempts === 0) {
526
570
  this.emit(EngineEvent.Restarting);
527
571
  }
528
572
 
@@ -550,7 +594,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
550
594
  this.emit(EngineEvent.Restarted, joinResponse);
551
595
  }
552
596
 
553
- private async resumeConnection(): Promise<void> {
597
+ private async resumeConnection(emitResuming: boolean = false): Promise<void> {
554
598
  if (!this.url || !this.token) {
555
599
  // permanent failure, don't attempt reconnection
556
600
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
@@ -561,14 +605,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
561
605
  }
562
606
 
563
607
  log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`);
564
- if (this.reconnectAttempts === 0) {
608
+ if (emitResuming || this.reconnectAttempts === 0) {
565
609
  this.emit(EngineEvent.Resuming);
566
610
  }
567
611
 
568
612
  try {
569
613
  await this.client.reconnect(this.url, this.token);
570
614
  } catch (e) {
571
- throw new SignalReconnectError();
615
+ let message = '';
616
+ if (e instanceof Error) {
617
+ message = e.message;
618
+ }
619
+ throw new SignalReconnectError(message);
572
620
  }
573
621
  this.emit(EngineEvent.SignalResumed);
574
622
 
@@ -0,0 +1,25 @@
1
+ /** Controls reconnecting of the client */
2
+ export interface ReconnectPolicy {
3
+ /** Called after disconnect was detected
4
+ *
5
+ * @returns {number | null} Amount of time in milliseconds to delay the next reconnect attempt, `null` signals to stop retrying.
6
+ */
7
+ nextRetryDelayInMs(context: ReconnectContext): number | null;
8
+ }
9
+
10
+ export interface ReconnectContext {
11
+ /**
12
+ * Number of failed reconnect attempts
13
+ */
14
+ readonly retryCount: number;
15
+
16
+ /**
17
+ * Elapsed amount of time in milliseconds since the disconnect.
18
+ */
19
+ readonly elapsedMs: number;
20
+
21
+ /**
22
+ * Reason for retrying
23
+ */
24
+ readonly retryReason?: Error;
25
+ }
package/src/room/Room.ts CHANGED
@@ -134,9 +134,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
134
134
  return;
135
135
  }
136
136
 
137
- this.engine = new RTCEngine();
137
+ this.engine = new RTCEngine(this.options);
138
138
 
139
- this.engine.client.signalLatency = this.options.expSignalLatency;
140
139
  this.engine.client.onParticipantUpdate = this.handleParticipantUpdates;
141
140
  this.engine.client.onRoomUpdate = this.handleRoomUpdate;
142
141
  this.engine.client.onSpeakersChanged = this.handleSpeakersChanged;
@@ -255,36 +254,16 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
255
254
  this.localParticipant.updateInfo(pi);
256
255
  // forward metadata changed for the local participant
257
256
  this.localParticipant
258
- .on(ParticipantEvent.ParticipantMetadataChanged, (metadata: string | undefined) => {
259
- this.emit(RoomEvent.ParticipantMetadataChanged, metadata, this.localParticipant);
260
- })
261
- .on(ParticipantEvent.TrackMuted, (pub: TrackPublication) => {
262
- this.emit(RoomEvent.TrackMuted, pub, this.localParticipant);
263
- })
264
- .on(ParticipantEvent.TrackUnmuted, (pub: TrackPublication) => {
265
- this.emit(RoomEvent.TrackUnmuted, pub, this.localParticipant);
266
- })
267
- .on(ParticipantEvent.LocalTrackPublished, (pub: LocalTrackPublication) => {
268
- this.emit(RoomEvent.LocalTrackPublished, pub, this.localParticipant);
269
- })
270
- .on(ParticipantEvent.LocalTrackUnpublished, (pub: LocalTrackPublication) => {
271
- this.emit(RoomEvent.LocalTrackUnpublished, pub, this.localParticipant);
272
- })
273
- .on(ParticipantEvent.ConnectionQualityChanged, (quality: ConnectionQuality) => {
274
- this.emit(RoomEvent.ConnectionQualityChanged, quality, this.localParticipant);
275
- })
276
- .on(ParticipantEvent.MediaDevicesError, (e: Error) => {
277
- this.emit(RoomEvent.MediaDevicesError, e);
278
- })
257
+ .on(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
258
+ .on(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
259
+ .on(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
260
+ .on(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
261
+ .on(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
262
+ .on(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
263
+ .on(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
279
264
  .on(
280
265
  ParticipantEvent.ParticipantPermissionsChanged,
281
- (prevPermissions: ParticipantPermission) => {
282
- this.emit(
283
- RoomEvent.ParticipantPermissionsChanged,
284
- prevPermissions,
285
- this.localParticipant,
286
- );
287
- },
266
+ this.onLocalParticipantPermissionsChanged,
288
267
  );
289
268
 
290
269
  // populate remote participants, these should not trigger new events
@@ -657,6 +636,19 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
657
636
  });
658
637
  });
659
638
 
639
+ this.localParticipant
640
+ .off(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
641
+ .off(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
642
+ .off(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
643
+ .off(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
644
+ .off(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
645
+ .off(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
646
+ .off(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
647
+ .off(
648
+ ParticipantEvent.ParticipantPermissionsChanged,
649
+ this.onLocalParticipantPermissionsChanged,
650
+ );
651
+
660
652
  this.localParticipant.tracks.forEach((pub) => {
661
653
  if (pub.track) {
662
654
  this.localParticipant.unpublishTrack(pub.track, shouldStopTracks);
@@ -666,6 +658,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
666
658
  pub.track?.stop();
667
659
  }
668
660
  });
661
+
669
662
  this.localParticipant.tracks.clear();
670
663
  this.localParticipant.videoTracks.clear();
671
664
  this.localParticipant.audioTracks.clear();
@@ -1081,6 +1074,38 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1081
1074
  return false;
1082
1075
  }
1083
1076
 
1077
+ private onLocalParticipantMetadataChanged = (metadata: string | undefined) => {
1078
+ this.emit(RoomEvent.ParticipantMetadataChanged, metadata, this.localParticipant);
1079
+ };
1080
+
1081
+ private onLocalTrackMuted = (pub: TrackPublication) => {
1082
+ this.emit(RoomEvent.TrackMuted, pub, this.localParticipant);
1083
+ };
1084
+
1085
+ private onLocalTrackUnmuted = (pub: TrackPublication) => {
1086
+ this.emit(RoomEvent.TrackUnmuted, pub, this.localParticipant);
1087
+ };
1088
+
1089
+ private onLocalTrackPublished = (pub: LocalTrackPublication) => {
1090
+ this.emit(RoomEvent.LocalTrackPublished, pub, this.localParticipant);
1091
+ };
1092
+
1093
+ private onLocalTrackUnpublished = (pub: LocalTrackPublication) => {
1094
+ this.emit(RoomEvent.LocalTrackUnpublished, pub, this.localParticipant);
1095
+ };
1096
+
1097
+ private onLocalConnectionQualityChanged = (quality: ConnectionQuality) => {
1098
+ this.emit(RoomEvent.ConnectionQualityChanged, quality, this.localParticipant);
1099
+ };
1100
+
1101
+ private onMediaDevicesError = (e: Error) => {
1102
+ this.emit(RoomEvent.MediaDevicesError, e);
1103
+ };
1104
+
1105
+ private onLocalParticipantPermissionsChanged = (prevPermissions: ParticipantPermission) => {
1106
+ this.emit(RoomEvent.ParticipantPermissionsChanged, prevPermissions, this.localParticipant);
1107
+ };
1108
+
1084
1109
  // /** @internal */
1085
1110
  emit<E extends keyof RoomEventCallbacks>(
1086
1111
  event: E,
@@ -218,8 +218,8 @@ export enum RoomEvent {
218
218
  * When we have encountered an error while attempting to create a track.
219
219
  * The errors take place in getUserMedia().
220
220
  * Use MediaDeviceFailure.getFailure(error) to get the reason of failure.
221
- * [[getAudioCreateError]] and [[getVideoCreateError]] will indicate if it had
222
- * an error while creating the audio or video track respectively.
221
+ * [[LocalParticipant.lastCameraError]] and [[LocalParticipant.lastMicrophoneError]]
222
+ * will indicate if it had an error while creating the audio or video track respectively.
223
223
  *
224
224
  * args: (error: Error)
225
225
  */
@@ -125,8 +125,9 @@ export default class LocalParticipant extends Participant {
125
125
  setCameraEnabled(
126
126
  enabled: boolean,
127
127
  options?: VideoCaptureOptions,
128
+ publishOptions?: TrackPublishOptions,
128
129
  ): Promise<LocalTrackPublication | undefined> {
129
- return this.setTrackEnabled(Track.Source.Camera, enabled, options);
130
+ return this.setTrackEnabled(Track.Source.Camera, enabled, options, publishOptions);
130
131
  }
131
132
 
132
133
  /**
@@ -138,8 +139,9 @@ export default class LocalParticipant extends Participant {
138
139
  setMicrophoneEnabled(
139
140
  enabled: boolean,
140
141
  options?: AudioCaptureOptions,
142
+ publishOptions?: TrackPublishOptions,
141
143
  ): Promise<LocalTrackPublication | undefined> {
142
- return this.setTrackEnabled(Track.Source.Microphone, enabled, options);
144
+ return this.setTrackEnabled(Track.Source.Microphone, enabled, options, publishOptions);
143
145
  }
144
146
 
145
147
  /**
@@ -149,8 +151,9 @@ export default class LocalParticipant extends Participant {
149
151
  setScreenShareEnabled(
150
152
  enabled: boolean,
151
153
  options?: ScreenShareCaptureOptions,
154
+ publishOptions?: TrackPublishOptions,
152
155
  ): Promise<LocalTrackPublication | undefined> {
153
- return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options);
156
+ return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions);
154
157
  }
155
158
 
156
159
  /** @internal */
@@ -172,21 +175,25 @@ export default class LocalParticipant extends Participant {
172
175
  source: Extract<Track.Source, Track.Source.Camera>,
173
176
  enabled: boolean,
174
177
  options?: VideoCaptureOptions,
178
+ publishOptions?: TrackPublishOptions,
175
179
  ): Promise<LocalTrackPublication | undefined>;
176
180
  private async setTrackEnabled(
177
181
  source: Extract<Track.Source, Track.Source.Microphone>,
178
182
  enabled: boolean,
179
183
  options?: AudioCaptureOptions,
184
+ publishOptions?: TrackPublishOptions,
180
185
  ): Promise<LocalTrackPublication | undefined>;
181
186
  private async setTrackEnabled(
182
187
  source: Extract<Track.Source, Track.Source.ScreenShare>,
183
188
  enabled: boolean,
184
189
  options?: ScreenShareCaptureOptions,
190
+ publishOptions?: TrackPublishOptions,
185
191
  ): Promise<LocalTrackPublication | undefined>;
186
192
  private async setTrackEnabled(
187
193
  source: Track.Source,
188
194
  enabled: true,
189
195
  options?: VideoCaptureOptions | AudioCaptureOptions | ScreenShareCaptureOptions,
196
+ publishOptions?: TrackPublishOptions,
190
197
  ) {
191
198
  log.debug('setTrackEnabled', { source, enabled });
192
199
  let track = this.getTrack(source);
@@ -224,7 +231,7 @@ export default class LocalParticipant extends Participant {
224
231
  }
225
232
  const publishPromises: Array<Promise<LocalTrackPublication>> = [];
226
233
  for (const localTrack of localTracks) {
227
- publishPromises.push(this.publishTrack(localTrack));
234
+ publishPromises.push(this.publishTrack(localTrack, publishOptions));
228
235
  }
229
236
  const publishedTracks = await Promise.all(publishPromises);
230
237
  // for screen share publications including audio, this will only return the screen share publication, not the screen share audio one
@@ -427,6 +434,23 @@ export default class LocalParticipant extends Participant {
427
434
  if (opts.source) {
428
435
  track.source = opts.source;
429
436
  }
437
+ const existingTrackOfSource = Array.from(this.tracks.values()).find(
438
+ (publishedTrack) => track instanceof LocalTrack && publishedTrack.source === track.source,
439
+ );
440
+ if (existingTrackOfSource) {
441
+ try {
442
+ // throw an Error in order to capture the stack trace
443
+ throw Error(`publishing a second track with the same source: ${track.source}`);
444
+ } catch (e: unknown) {
445
+ if (e instanceof Error) {
446
+ log.warn(e.message, {
447
+ oldTrack: existingTrackOfSource,
448
+ newTrack: track,
449
+ trace: e.stack,
450
+ });
451
+ }
452
+ }
453
+ }
430
454
  if (opts.stopMicTrackOnMute && track instanceof LocalAudioTrack) {
431
455
  track.stopOnMute = true;
432
456
  }
@@ -544,6 +568,14 @@ export default class LocalParticipant extends Participant {
544
568
  track.codec = opts.videoCodec;
545
569
  }
546
570
 
571
+ if (track.codec === 'av1' && encodings && encodings[0]?.maxBitrate) {
572
+ this.engine.publisher.setTrackCodecBitrate(
573
+ req.cid,
574
+ track.codec,
575
+ encodings[0].maxBitrate / 1000,
576
+ );
577
+ }
578
+
547
579
  this.engine.negotiate();
548
580
 
549
581
  // store RTPSender
@@ -642,6 +674,13 @@ export default class LocalParticipant extends Participant {
642
674
  this.setPreferredCodec(transceiver, track.kind, opts.videoCodec);
643
675
  track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
644
676
 
677
+ if (videoCodec === 'av1' && encodings[0]?.maxBitrate) {
678
+ this.engine.publisher.setTrackCodecBitrate(
679
+ req.cid,
680
+ videoCodec,
681
+ encodings[0].maxBitrate / 1000,
682
+ );
683
+ }
645
684
  this.engine.negotiate();
646
685
  log.debug(`published ${opts.videoCodec} for track ${track.sid}`, { encodings, trackInfo: ti });
647
686
  }
@@ -664,8 +703,6 @@ export default class LocalParticipant extends Participant {
664
703
  }
665
704
 
666
705
  track = publication.track;
667
-
668
- track.sender = undefined;
669
706
  track.off(TrackEvent.Muted, this.onTrackMuted);
670
707
  track.off(TrackEvent.Unmuted, this.onTrackUnmuted);
671
708
  track.off(TrackEvent.Ended, this.handleTrackEnded);
@@ -679,22 +716,21 @@ export default class LocalParticipant extends Participant {
679
716
  track.stop();
680
717
  }
681
718
 
682
- const { mediaStreamTrack } = track;
683
-
684
- if (this.engine.publisher && this.engine.publisher.pc.connectionState !== 'closed') {
685
- const senders = this.engine.publisher.pc.getSenders();
686
- senders.forEach((sender) => {
687
- if (sender.track === mediaStreamTrack) {
688
- try {
689
- this.engine.publisher?.pc.removeTrack(sender);
690
- this.engine.negotiate();
691
- } catch (e) {
692
- log.warn('failed to remove track', { error: e, method: 'unpublishTrack' });
693
- }
694
- }
695
- });
719
+ if (
720
+ this.engine.publisher &&
721
+ this.engine.publisher.pc.connectionState !== 'closed' &&
722
+ track.sender
723
+ ) {
724
+ try {
725
+ this.engine.publisher.pc.removeTrack(track.sender);
726
+ this.engine.negotiate();
727
+ } catch (e) {
728
+ log.warn('failed to remove track', { error: e, method: 'unpublishTrack' });
729
+ }
696
730
  }
697
731
 
732
+ track.sender = undefined;
733
+
698
734
  // remove from our maps
699
735
  this.tracks.delete(publication.trackSid);
700
736
  switch (publication.kind) {
@@ -220,6 +220,19 @@ export default class RemoteParticipant extends Participant {
220
220
  // always emit events for new publications, Room will not forward them unless it's ready
221
221
  newTracks.forEach((publication) => {
222
222
  this.emit(ParticipantEvent.TrackPublished, publication);
223
+ const existingTrackOfSource = Array.from(this.tracks.values()).find(
224
+ (publishedTrack) => publishedTrack.source === publication.source,
225
+ );
226
+ if (existingTrackOfSource) {
227
+ log.warn(
228
+ `received a second track publication for ${this.identity} with the same source: ${publication.source}`,
229
+ {
230
+ oldTrack: existingTrackOfSource,
231
+ newTrack: publication,
232
+ participant: this,
233
+ },
234
+ );
235
+ }
223
236
  });
224
237
 
225
238
  // detect removed tracks
@@ -106,7 +106,7 @@ export function computeVideoEncodings(
106
106
  encodings.push({
107
107
  rid: videoRids[2 - i],
108
108
  scaleResolutionDownBy: 2 ** i,
109
- maxBitrate: videoEncoding ? videoEncoding.maxBitrate / 2 ** i : 0,
109
+ maxBitrate: videoEncoding ? videoEncoding.maxBitrate / 3 ** i : 0,
110
110
  /* @ts-ignore */
111
111
  maxFramerate: original.encoding.maxFramerate,
112
112
  /* @ts-ignore */
@@ -75,7 +75,6 @@ export default class LocalVideoTrack extends LocalTrack {
75
75
  }
76
76
 
77
77
  stop() {
78
- this.sender = undefined;
79
78
  this._mediaStreamTrack.getConstraints();
80
79
  this.simulcastCodecs.forEach((trackInfo) => {
81
80
  trackInfo.mediaStreamTrack.stop();