livekit-client 1.2.2 → 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 (34) hide show
  1. package/dist/livekit-client.esm.mjs +972 -109
  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 +1 -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.map +1 -1
  21. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  22. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  23. package/package.json +3 -1
  24. package/src/index.ts +2 -0
  25. package/src/options.ts +6 -0
  26. package/src/room/DefaultReconnectPolicy.ts +35 -0
  27. package/src/room/PCTransport.ts +91 -17
  28. package/src/room/RTCEngine.ts +79 -31
  29. package/src/room/ReconnectPolicy.ts +25 -0
  30. package/src/room/Room.ts +55 -30
  31. package/src/room/events.ts +2 -2
  32. package/src/room/participant/LocalParticipant.ts +30 -16
  33. package/src/room/participant/RemoteParticipant.ts +13 -0
  34. 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
  */
@@ -434,6 +434,23 @@ export default class LocalParticipant extends Participant {
434
434
  if (opts.source) {
435
435
  track.source = opts.source;
436
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
+ }
437
454
  if (opts.stopMicTrackOnMute && track instanceof LocalAudioTrack) {
438
455
  track.stopOnMute = true;
439
456
  }
@@ -686,8 +703,6 @@ export default class LocalParticipant extends Participant {
686
703
  }
687
704
 
688
705
  track = publication.track;
689
-
690
- track.sender = undefined;
691
706
  track.off(TrackEvent.Muted, this.onTrackMuted);
692
707
  track.off(TrackEvent.Unmuted, this.onTrackUnmuted);
693
708
  track.off(TrackEvent.Ended, this.handleTrackEnded);
@@ -701,22 +716,21 @@ export default class LocalParticipant extends Participant {
701
716
  track.stop();
702
717
  }
703
718
 
704
- const { mediaStreamTrack } = track;
705
-
706
- if (this.engine.publisher && this.engine.publisher.pc.connectionState !== 'closed') {
707
- const senders = this.engine.publisher.pc.getSenders();
708
- senders.forEach((sender) => {
709
- if (sender.track === mediaStreamTrack) {
710
- try {
711
- this.engine.publisher?.pc.removeTrack(sender);
712
- this.engine.negotiate();
713
- } catch (e) {
714
- log.warn('failed to remove track', { error: e, method: 'unpublishTrack' });
715
- }
716
- }
717
- });
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
+ }
718
730
  }
719
731
 
732
+ track.sender = undefined;
733
+
720
734
  // remove from our maps
721
735
  this.tracks.delete(publication.trackSid);
722
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
@@ -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();