livekit-client 1.9.0 → 1.9.2

Sign up to get free protection for your applications and to get access to all the features.
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 {