livekit-client 1.9.0 → 1.9.2

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.
package/src/room/Room.ts CHANGED
@@ -45,6 +45,7 @@ import LocalParticipant from './participant/LocalParticipant';
45
45
  import type Participant from './participant/Participant';
46
46
  import type { ConnectionQuality } from './participant/Participant';
47
47
  import RemoteParticipant from './participant/RemoteParticipant';
48
+ import CriticalTimers from './timers';
48
49
  import LocalAudioTrack from './track/LocalAudioTrack';
49
50
  import LocalTrackPublication from './track/LocalTrackPublication';
50
51
  import LocalVideoTrack from './track/LocalVideoTrack';
@@ -73,6 +74,8 @@ export enum ConnectionState {
73
74
  Reconnecting = 'reconnecting',
74
75
  }
75
76
 
77
+ const connectionReconcileFrequency = 2 * 1000;
78
+
76
79
  /** @deprecated RoomState has been renamed to [[ConnectionState]] */
77
80
  export const RoomState = ConnectionState;
78
81
 
@@ -124,6 +127,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
124
127
 
125
128
  private disconnectLock: Mutex;
126
129
 
130
+ private cachedParticipantSids: Array<string>;
131
+
132
+ private connectionReconcileInterval?: ReturnType<typeof setInterval>;
133
+
127
134
  /**
128
135
  * Creates a new Room, the primary construct for a LiveKit session.
129
136
  * @param options
@@ -132,6 +139,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
132
139
  super();
133
140
  this.setMaxListeners(100);
134
141
  this.participants = new Map();
142
+ this.cachedParticipantSids = [];
135
143
  this.identityToSid = new Map();
136
144
  this.options = { ...roomOptionDefaults, ...options };
137
145
 
@@ -186,7 +194,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
186
194
  }
187
195
 
188
196
  private maybeCreateEngine() {
189
- if (this.engine) {
197
+ if (this.engine && !this.engine.isClosed) {
190
198
  return;
191
199
  }
192
200
 
@@ -212,14 +220,24 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
212
220
  .on(EngineEvent.ActiveSpeakersUpdate, this.handleActiveSpeakersUpdate)
213
221
  .on(EngineEvent.DataPacketReceived, this.handleDataPacket)
214
222
  .on(EngineEvent.Resuming, () => {
223
+ this.clearConnectionReconcile();
215
224
  if (this.setAndEmitConnectionState(ConnectionState.Reconnecting)) {
216
225
  this.emit(RoomEvent.Reconnecting);
217
226
  }
227
+ this.cachedParticipantSids = Array.from(this.participants.keys());
218
228
  })
219
229
  .on(EngineEvent.Resumed, () => {
220
230
  this.setAndEmitConnectionState(ConnectionState.Connected);
221
231
  this.emit(RoomEvent.Reconnected);
232
+ this.registerConnectionReconcile();
222
233
  this.updateSubscriptions();
234
+
235
+ // once reconnected, figure out if any participants connected during reconnect and emit events for it
236
+ const diffParticipants = Array.from(this.participants.values()).filter(
237
+ (p) => !this.cachedParticipantSids.includes(p.sid),
238
+ );
239
+ diffParticipants.forEach((p) => this.emit(RoomEvent.ParticipantConnected, p));
240
+ this.cachedParticipantSids = [];
223
241
  })
224
242
  .on(EngineEvent.SignalResumed, () => {
225
243
  if (this.state === ConnectionState.Reconnecting) {
@@ -477,6 +495,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
477
495
  }
478
496
  this.setAndEmitConnectionState(ConnectionState.Connected);
479
497
  this.emit(RoomEvent.Connected);
498
+ this.registerConnectionReconcile();
480
499
  };
481
500
 
482
501
  /**
@@ -818,6 +837,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
818
837
  }
819
838
 
820
839
  private handleRestarting = () => {
840
+ this.clearConnectionReconcile();
821
841
  // also unwind existing participants & existing subscriptions
822
842
  for (const p of this.participants.values()) {
823
843
  this.handleParticipantDisconnected(p.sid, p);
@@ -833,6 +853,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
833
853
  region: joinResponse.serverRegion,
834
854
  });
835
855
 
856
+ this.cachedParticipantSids = [];
836
857
  this.applyJoinResponse(joinResponse);
837
858
 
838
859
  try {
@@ -882,6 +903,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
882
903
  }
883
904
  this.setAndEmitConnectionState(ConnectionState.Connected);
884
905
  this.emit(RoomEvent.Reconnected);
906
+ this.registerConnectionReconcile();
885
907
 
886
908
  // emit participant connected events after connection has been re-established
887
909
  this.participants.forEach((participant) => {
@@ -890,57 +912,61 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
890
912
  };
891
913
 
892
914
  private handleDisconnect(shouldStopTracks = true, reason?: DisconnectReason) {
915
+ this.clearConnectionReconcile();
893
916
  if (this.state === ConnectionState.Disconnected) {
894
917
  return;
895
918
  }
896
919
 
897
- this.participants.forEach((p) => {
898
- p.tracks.forEach((pub) => {
899
- p.unpublishTrack(pub.trackSid);
920
+ try {
921
+ this.participants.forEach((p) => {
922
+ p.tracks.forEach((pub) => {
923
+ p.unpublishTrack(pub.trackSid);
924
+ });
900
925
  });
901
- });
902
926
 
903
- this.localParticipant.tracks.forEach((pub) => {
904
- if (pub.track) {
905
- this.localParticipant.unpublishTrack(pub.track, shouldStopTracks);
906
- }
907
- if (shouldStopTracks) {
908
- pub.track?.detach();
909
- pub.track?.stop();
910
- }
911
- });
927
+ this.localParticipant.tracks.forEach((pub) => {
928
+ if (pub.track) {
929
+ this.localParticipant.unpublishTrack(pub.track, shouldStopTracks);
930
+ }
931
+ if (shouldStopTracks) {
932
+ pub.track?.detach();
933
+ pub.track?.stop();
934
+ }
935
+ });
912
936
 
913
- this.localParticipant
914
- .off(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
915
- .off(ParticipantEvent.ParticipantNameChanged, this.onLocalParticipantNameChanged)
916
- .off(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
917
- .off(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
918
- .off(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
919
- .off(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
920
- .off(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
921
- .off(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
922
- .off(
923
- ParticipantEvent.ParticipantPermissionsChanged,
924
- this.onLocalParticipantPermissionsChanged,
925
- );
937
+ this.localParticipant
938
+ .off(ParticipantEvent.ParticipantMetadataChanged, this.onLocalParticipantMetadataChanged)
939
+ .off(ParticipantEvent.ParticipantNameChanged, this.onLocalParticipantNameChanged)
940
+ .off(ParticipantEvent.TrackMuted, this.onLocalTrackMuted)
941
+ .off(ParticipantEvent.TrackUnmuted, this.onLocalTrackUnmuted)
942
+ .off(ParticipantEvent.LocalTrackPublished, this.onLocalTrackPublished)
943
+ .off(ParticipantEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished)
944
+ .off(ParticipantEvent.ConnectionQualityChanged, this.onLocalConnectionQualityChanged)
945
+ .off(ParticipantEvent.MediaDevicesError, this.onMediaDevicesError)
946
+ .off(
947
+ ParticipantEvent.ParticipantPermissionsChanged,
948
+ this.onLocalParticipantPermissionsChanged,
949
+ );
926
950
 
927
- this.localParticipant.tracks.clear();
928
- this.localParticipant.videoTracks.clear();
929
- this.localParticipant.audioTracks.clear();
951
+ this.localParticipant.tracks.clear();
952
+ this.localParticipant.videoTracks.clear();
953
+ this.localParticipant.audioTracks.clear();
930
954
 
931
- this.participants.clear();
932
- this.activeSpeakers = [];
933
- if (this.audioContext && typeof this.options.expWebAudioMix === 'boolean') {
934
- this.audioContext.close();
935
- this.audioContext = undefined;
936
- }
937
- if (isWeb()) {
938
- window.removeEventListener('beforeunload', this.onPageLeave);
939
- window.removeEventListener('pagehide', this.onPageLeave);
940
- navigator.mediaDevices?.removeEventListener('devicechange', this.handleDeviceChange);
955
+ this.participants.clear();
956
+ this.activeSpeakers = [];
957
+ if (this.audioContext && typeof this.options.expWebAudioMix === 'boolean') {
958
+ this.audioContext.close();
959
+ this.audioContext = undefined;
960
+ }
961
+ if (isWeb()) {
962
+ window.removeEventListener('beforeunload', this.onPageLeave);
963
+ window.removeEventListener('pagehide', this.onPageLeave);
964
+ navigator.mediaDevices?.removeEventListener('devicechange', this.handleDeviceChange);
965
+ }
966
+ } finally {
967
+ this.setAndEmitConnectionState(ConnectionState.Disconnected);
968
+ this.emit(RoomEvent.Disconnected, reason);
941
969
  }
942
- this.setAndEmitConnectionState(ConnectionState.Disconnected);
943
- this.emit(RoomEvent.Disconnected, reason);
944
970
  }
945
971
 
946
972
  private handleParticipantUpdates = (participantInfos: ParticipantInfo[]) => {
@@ -1329,6 +1355,37 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1329
1355
  }
1330
1356
  }
1331
1357
 
1358
+ private registerConnectionReconcile() {
1359
+ this.clearConnectionReconcile();
1360
+ let consecutiveFailures = 0;
1361
+ this.connectionReconcileInterval = CriticalTimers.setInterval(() => {
1362
+ if (
1363
+ // ensure we didn't tear it down
1364
+ !this.engine ||
1365
+ // engine detected close, but Room missed it
1366
+ this.engine.isClosed ||
1367
+ // transports failed without notifying engine
1368
+ !this.engine.verifyTransport()
1369
+ ) {
1370
+ consecutiveFailures++;
1371
+ log.warn('detected connection state mismatch', { numFailures: consecutiveFailures });
1372
+ if (consecutiveFailures >= 3)
1373
+ this.handleDisconnect(
1374
+ this.options.stopLocalTrackOnUnpublish,
1375
+ DisconnectReason.UNKNOWN_REASON,
1376
+ );
1377
+ } else {
1378
+ consecutiveFailures = 0;
1379
+ }
1380
+ }, connectionReconcileFrequency);
1381
+ }
1382
+
1383
+ private clearConnectionReconcile() {
1384
+ if (this.connectionReconcileInterval) {
1385
+ CriticalTimers.clearInterval(this.connectionReconcileInterval);
1386
+ }
1387
+ }
1388
+
1332
1389
  private setAndEmitConnectionState(state: ConnectionState): boolean {
1333
1390
  if (state === this.state) {
1334
1391
  // unchanged
@@ -27,10 +27,11 @@ import {
27
27
  TrackPublishOptions,
28
28
  VideoCaptureOptions,
29
29
  isBackupCodec,
30
+ isCodecEqual,
30
31
  } from '../track/options';
31
32
  import { constraintsForOptions, mergeDefaultOptions } from '../track/utils';
32
33
  import type { DataPublishOptions } from '../types';
33
- import { Future, isFireFox, isSafari, isWeb, supportsAV1 } from '../utils';
34
+ import { Future, isFireFox, isSVCCodec, isSafari, isWeb, supportsAV1, supportsVP9 } from '../utils';
34
35
  import Participant from './Participant';
35
36
  import { ParticipantTrackPermission, trackPermissionToProto } from './ParticipantTrackPermission';
36
37
  import RemoteParticipant from './RemoteParticipant';
@@ -570,10 +571,13 @@ export default class LocalParticipant extends Participant {
570
571
  opts.simulcast = false;
571
572
  }
572
573
 
573
- // require full AV1 SVC support prior to using it
574
+ // require full AV1/VP9 SVC support prior to using it
574
575
  if (opts.videoCodec === 'av1' && !supportsAV1()) {
575
576
  opts.videoCodec = undefined;
576
577
  }
578
+ if (opts.videoCodec === 'vp9' && !supportsVP9()) {
579
+ opts.videoCodec = undefined;
580
+ }
577
581
 
578
582
  // handle track actions
579
583
  track.on(TrackEvent.Muted, this.onTrackMuted);
@@ -614,7 +618,7 @@ export default class LocalParticipant extends Participant {
614
618
  req.height = dims.height;
615
619
  // for svc codecs, disable simulcast and use vp8 for backup codec
616
620
  if (track instanceof LocalVideoTrack) {
617
- if (opts?.videoCodec === 'av1') {
621
+ if (isSVCCodec(opts.videoCodec)) {
618
622
  // set scalabilityMode to 'L3T3' by default
619
623
  opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3';
620
624
  }
@@ -660,6 +664,33 @@ export default class LocalParticipant extends Participant {
660
664
  }
661
665
 
662
666
  const ti = await this.engine.addTrack(req);
667
+ let primaryCodecSupported = false;
668
+ let backupCodecSupported = false;
669
+ ti.codecs.forEach((c) => {
670
+ if (isCodecEqual(c.mimeType, opts.videoCodec)) {
671
+ primaryCodecSupported = true;
672
+ } else if (opts.backupCodec && isCodecEqual(c.mimeType, opts.backupCodec.codec)) {
673
+ backupCodecSupported = true;
674
+ }
675
+ });
676
+
677
+ if (req.simulcastCodecs.length > 0) {
678
+ if (!primaryCodecSupported && !backupCodecSupported) {
679
+ throw Error('cannot publish track, codec not supported by server');
680
+ }
681
+
682
+ if (!primaryCodecSupported && opts.backupCodec) {
683
+ const backupCodec = opts.backupCodec;
684
+ opts = { ...opts };
685
+ log.debug(
686
+ `primary codec ${opts.videoCodec} not supported, fallback to ${backupCodec.codec}`,
687
+ );
688
+ opts.videoCodec = backupCodec.codec;
689
+ opts.videoEncoding = backupCodec.encoding;
690
+ encodings = simEncodings;
691
+ }
692
+ }
693
+
663
694
  const publication = new LocalTrackPublication(track.kind, ti, track);
664
695
  // save options for when it needs to be republished again
665
696
  publication.options = opts;
@@ -673,7 +704,7 @@ export default class LocalParticipant extends Participant {
673
704
  // store RTPSender
674
705
  track.sender = await this.engine.createSender(track, opts, encodings);
675
706
 
676
- if (track.codec === 'av1' && encodings && encodings[0]?.maxBitrate) {
707
+ if (track.codec && isSVCCodec(track.codec) && encodings && encodings[0]?.maxBitrate) {
677
708
  this.engine.publisher.setTrackCodecBitrate(
678
709
  req.cid,
679
710
  track.codec,
@@ -13,6 +13,7 @@ import {
13
13
  VideoPresets,
14
14
  VideoPresets43,
15
15
  } from '../track/options';
16
+ import { isSVCCodec } from '../utils';
16
17
 
17
18
  /** @internal */
18
19
  export function mediaTrackToLocalTrack(
@@ -121,7 +122,7 @@ export function computeVideoEncodings(
121
122
  videoEncoding.maxFramerate,
122
123
  );
123
124
 
124
- if (scalabilityMode && videoCodec === 'av1') {
125
+ if (scalabilityMode && isSVCCodec(videoCodec)) {
125
126
  log.debug(`using svc with scalabilityMode ${scalabilityMode}`);
126
127
 
127
128
  const encodings: RTCRtpEncodingParameters[] = [];
@@ -248,8 +249,13 @@ export function determineAppropriateEncoding(
248
249
  if (codec) {
249
250
  switch (codec) {
250
251
  case 'av1':
252
+ encoding = { ...encoding };
251
253
  encoding.maxBitrate = encoding.maxBitrate * 0.7;
252
254
  break;
255
+ case 'vp9':
256
+ encoding = { ...encoding };
257
+ encoding.maxBitrate = encoding.maxBitrate * 0.85;
258
+ break;
253
259
  default:
254
260
  break;
255
261
  }
@@ -303,13 +309,15 @@ function encodingsFromPresets(
303
309
  }
304
310
  const size = Math.min(width, height);
305
311
  const rid = videoRids[idx];
306
- encodings.push({
312
+ const encoding: RTCRtpEncodingParameters = {
307
313
  rid,
308
314
  scaleResolutionDownBy: Math.max(1, size / Math.min(preset.width, preset.height)),
309
315
  maxBitrate: preset.encoding.maxBitrate,
310
- /* @ts-ignore */
311
- maxFramerate: preset.encoding.maxFramerate,
312
- });
316
+ };
317
+ if (preset.encoding.maxFramerate) {
318
+ encoding.maxFramerate = preset.encoding.maxFramerate;
319
+ }
320
+ encodings.push(encoding);
313
321
  });
314
322
  return encodings;
315
323
  }
@@ -145,6 +145,12 @@ export interface ScreenShareCaptureOptions {
145
145
 
146
146
  /** specifies whether the browser should include the system audio among the possible audio sources offered to the user */
147
147
  systemAudio?: 'include' | 'exclude';
148
+
149
+ /**
150
+ * Experimental option to control whether the audio playing in a tab will continue to be played out of a user's
151
+ * local speakers when the tab is captured.
152
+ */
153
+ suppressLocalAudioPlayback?: boolean;
148
154
  }
149
155
 
150
156
  export interface AudioCaptureOptions {
@@ -241,7 +247,7 @@ export interface AudioPreset {
241
247
  maxBitrate: number;
242
248
  }
243
249
 
244
- const codecs = ['vp8', 'h264', 'av1'] as const;
250
+ const codecs = ['vp8', 'h264', 'vp9', 'av1'] as const;
245
251
  const backupCodecs = ['vp8', 'h264'] as const;
246
252
 
247
253
  export type VideoCodec = (typeof codecs)[number];
@@ -252,6 +258,13 @@ export function isBackupCodec(codec: string): codec is BackupVideoCodec {
252
258
  return !!backupCodecs.find((backup) => backup === codec);
253
259
  }
254
260
 
261
+ export function isCodecEqual(c1: string | undefined, c2: string | undefined): boolean {
262
+ return (
263
+ c1?.toLowerCase().replace(/audio\/|video\//y, '') ===
264
+ c2?.toLowerCase().replace(/audio\/|video\//y, '')
265
+ );
266
+ }
267
+
255
268
  /**
256
269
  * scalability modes for svc, only supprot l3t3 now.
257
270
  */
package/src/room/utils.ts CHANGED
@@ -7,6 +7,8 @@ import { getNewAudioContext } from './track/utils';
7
7
  import type { LiveKitReactNativeInfo } from './types';
8
8
 
9
9
  const separator = '|';
10
+ export const ddExtensionURI =
11
+ 'https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension';
10
12
 
11
13
  export function unpackStreamId(packed: string): string[] {
12
14
  const parts = packed.split(separator);
@@ -41,7 +43,6 @@ export function supportsDynacast() {
41
43
  export function supportsAV1(): boolean {
42
44
  const capabilities = RTCRtpReceiver.getCapabilities('video');
43
45
  let hasAV1 = false;
44
- let hasDDExt = false;
45
46
  if (capabilities) {
46
47
  for (const codec of capabilities.codecs) {
47
48
  if (codec.mimeType === 'video/AV1') {
@@ -49,17 +50,26 @@ export function supportsAV1(): boolean {
49
50
  break;
50
51
  }
51
52
  }
52
- for (const ext of capabilities.headerExtensions) {
53
- if (
54
- ext.uri ===
55
- 'https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension'
56
- ) {
57
- hasDDExt = true;
53
+ }
54
+ return hasAV1;
55
+ }
56
+
57
+ export function supportsVP9(): boolean {
58
+ const capabilities = RTCRtpReceiver.getCapabilities('video');
59
+ let hasVP9 = false;
60
+ if (capabilities) {
61
+ for (const codec of capabilities.codecs) {
62
+ if (codec.mimeType === 'video/VP9') {
63
+ hasVP9 = true;
58
64
  break;
59
65
  }
60
66
  }
61
67
  }
62
- return hasAV1 && hasDDExt;
68
+ return hasVP9;
69
+ }
70
+
71
+ export function isSVCCodec(codec?: string): boolean {
72
+ return codec === 'av1' || codec === 'vp9';
63
73
  }
64
74
 
65
75
  export function supportsSetSinkId(elm?: HTMLMediaElement): boolean {