livekit-client 2.5.6 → 2.5.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +628 -603
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2160 -1949
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.umd.js +1 -1
  9. package/dist/livekit-client.umd.js.map +1 -1
  10. package/dist/src/e2ee/worker/FrameCryptor.d.ts +2 -2
  11. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  12. package/dist/src/index.d.ts +2 -2
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/room/Room.d.ts +4 -1
  15. package/dist/src/room/Room.d.ts.map +1 -1
  16. package/dist/src/room/events.d.ts +7 -3
  17. package/dist/src/room/events.d.ts.map +1 -1
  18. package/dist/src/room/participant/LocalParticipant.d.ts +7 -0
  19. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  20. package/dist/src/room/participant/Participant.d.ts +1 -1
  21. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  22. package/dist/src/room/track/Track.d.ts +1 -1
  23. package/dist/src/room/track/Track.d.ts.map +1 -1
  24. package/dist/src/room/utils.d.ts +1 -1
  25. package/dist/src/room/utils.d.ts.map +1 -1
  26. package/dist/src/test/mocks.d.ts +1 -1
  27. package/dist/src/test/mocks.d.ts.map +1 -1
  28. package/dist/ts4.2/src/e2ee/worker/FrameCryptor.d.ts +2 -2
  29. package/dist/ts4.2/src/index.d.ts +2 -2
  30. package/dist/ts4.2/src/room/Room.d.ts +4 -1
  31. package/dist/ts4.2/src/room/events.d.ts +7 -3
  32. package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +7 -0
  33. package/dist/ts4.2/src/room/participant/Participant.d.ts +1 -1
  34. package/dist/ts4.2/src/room/track/Track.d.ts +1 -1
  35. package/dist/ts4.2/src/room/utils.d.ts +1 -1
  36. package/dist/ts4.2/src/test/mocks.d.ts +1 -1
  37. package/package.json +14 -15
  38. package/src/e2ee/worker/FrameCryptor.test.ts +249 -2
  39. package/src/e2ee/worker/FrameCryptor.ts +3 -3
  40. package/src/e2ee/worker/ParticipantKeyHandler.test.ts +122 -0
  41. package/src/e2ee/worker/ParticipantKeyHandler.ts +1 -1
  42. package/src/e2ee/worker/e2ee.worker.ts +1 -1
  43. package/src/index.ts +2 -0
  44. package/src/room/Room.ts +24 -10
  45. package/src/room/events.ts +7 -2
  46. package/src/room/participant/LocalParticipant.ts +22 -0
  47. package/src/room/participant/Participant.ts +3 -2
  48. package/src/room/track/LocalTrackPublication.ts +1 -1
  49. package/src/room/track/Track.ts +1 -1
  50. package/src/room/track/utils.ts +1 -1
  51. package/src/room/utils.ts +1 -1
  52. package/src/test/mocks.ts +1 -1
@@ -1,13 +1,109 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { isFrameServerInjected } from './FrameCryptor';
1
+ import { afterEach, describe, expect, it, vitest } from 'vitest';
2
+ import { IV_LENGTH, KEY_PROVIDER_DEFAULTS } from '../constants';
3
+ import { CryptorEvent } from '../events';
4
+ import type { KeyProviderOptions } from '../types';
5
+ import { createKeyMaterialFromString } from '../utils';
6
+ import { FrameCryptor, encryptionEnabledMap, isFrameServerInjected } from './FrameCryptor';
7
+ import { ParticipantKeyHandler } from './ParticipantKeyHandler';
8
+
9
+ function mockEncryptedRTCEncodedVideoFrame(keyIndex: number): RTCEncodedVideoFrame {
10
+ const trailer = mockFrameTrailer(keyIndex);
11
+ const data = new Uint8Array(trailer.length + 10);
12
+ data.set(trailer, 10);
13
+ return mockRTCEncodedVideoFrame(data);
14
+ }
15
+
16
+ function mockRTCEncodedVideoFrame(data: Uint8Array): RTCEncodedVideoFrame {
17
+ return {
18
+ data: data.buffer,
19
+ timestamp: vitest.getMockedSystemTime()?.getTime() ?? 0,
20
+ type: 'key',
21
+ getMetadata(): RTCEncodedVideoFrameMetadata {
22
+ return {};
23
+ },
24
+ };
25
+ }
26
+
27
+ function mockFrameTrailer(keyIndex: number): Uint8Array {
28
+ const frameTrailer = new Uint8Array(2);
29
+
30
+ frameTrailer[0] = IV_LENGTH;
31
+ frameTrailer[1] = keyIndex;
32
+
33
+ return frameTrailer;
34
+ }
35
+
36
+ class TestUnderlyingSource<T> implements UnderlyingSource<T> {
37
+ controller: ReadableStreamController<T>;
38
+
39
+ start(controller: ReadableStreamController<T>): void {
40
+ this.controller = controller;
41
+ }
42
+
43
+ write(chunk: T): void {
44
+ this.controller.enqueue(chunk as any);
45
+ }
46
+
47
+ close(): void {
48
+ this.controller.close();
49
+ }
50
+ }
51
+
52
+ class TestUnderlyingSink<T> implements UnderlyingSink<T> {
53
+ public chunks: T[] = [];
54
+
55
+ write(chunk: T): void {
56
+ this.chunks.push(chunk);
57
+ }
58
+ }
59
+
60
+ function prepareParticipantTestDecoder(
61
+ participantIdentity: string,
62
+ partialKeyProviderOptions: Partial<KeyProviderOptions>,
63
+ ): {
64
+ keys: ParticipantKeyHandler;
65
+ cryptor: FrameCryptor;
66
+ input: TestUnderlyingSource<RTCEncodedVideoFrame>;
67
+ output: TestUnderlyingSink<RTCEncodedVideoFrame>;
68
+ } {
69
+ const keyProviderOptions = { ...KEY_PROVIDER_DEFAULTS, ...partialKeyProviderOptions };
70
+ const keys = new ParticipantKeyHandler(participantIdentity, keyProviderOptions);
71
+
72
+ encryptionEnabledMap.set(participantIdentity, true);
73
+
74
+ const cryptor = new FrameCryptor({
75
+ participantIdentity,
76
+ keys,
77
+ keyProviderOptions,
78
+ sifTrailer: new Uint8Array(),
79
+ });
80
+
81
+ const input = new TestUnderlyingSource<RTCEncodedVideoFrame>();
82
+ const output = new TestUnderlyingSink<RTCEncodedVideoFrame>();
83
+ cryptor.setupTransform(
84
+ 'decode',
85
+ new ReadableStream(input),
86
+ new WritableStream(output),
87
+ 'testTrack',
88
+ );
89
+
90
+ return { keys, cryptor, input, output };
91
+ }
3
92
 
4
93
  describe('FrameCryptor', () => {
94
+ const participantIdentity = 'testParticipant';
95
+
96
+ afterEach(() => {
97
+ encryptionEnabledMap.clear();
98
+ });
99
+
5
100
  it('identifies server injected frame correctly', () => {
6
101
  const frameTrailer = new TextEncoder().encode('LKROCKS');
7
102
  const frameData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, ...frameTrailer]).buffer;
8
103
 
9
104
  expect(isFrameServerInjected(frameData, frameTrailer)).toBe(true);
10
105
  });
106
+
11
107
  it('identifies server non server injected frame correctly', () => {
12
108
  const frameTrailer = new TextEncoder().encode('LKROCKS');
13
109
  const frameData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, ...frameTrailer, 10]);
@@ -16,4 +112,155 @@ describe('FrameCryptor', () => {
16
112
  frameData.fill(0);
17
113
  expect(isFrameServerInjected(frameData.buffer, frameTrailer)).toBe(false);
18
114
  });
115
+
116
+ it('passthrough if participant encryption disabled', async () => {
117
+ vitest.useFakeTimers();
118
+ try {
119
+ const { input, output } = prepareParticipantTestDecoder(participantIdentity, {});
120
+
121
+ // disable encryption for participant
122
+ encryptionEnabledMap.set(participantIdentity, false);
123
+
124
+ const frame = mockEncryptedRTCEncodedVideoFrame(1);
125
+
126
+ input.write(frame);
127
+ await vitest.advanceTimersToNextTimerAsync();
128
+
129
+ expect(output.chunks).toEqual([frame]);
130
+ } finally {
131
+ vitest.useRealTimers();
132
+ }
133
+ });
134
+
135
+ it('passthrough for empty frame', async () => {
136
+ vitest.useFakeTimers();
137
+ try {
138
+ const { input, output } = prepareParticipantTestDecoder(participantIdentity, {});
139
+
140
+ // empty frame
141
+ const frame = mockRTCEncodedVideoFrame(new Uint8Array(0));
142
+
143
+ input.write(frame);
144
+ await vitest.advanceTimersToNextTimerAsync();
145
+
146
+ expect(output.chunks).toEqual([frame]);
147
+ } finally {
148
+ vitest.useRealTimers();
149
+ }
150
+ });
151
+
152
+ it('drops frames when invalid key', async () => {
153
+ vitest.useFakeTimers();
154
+ try {
155
+ const { keys, input, output } = prepareParticipantTestDecoder(participantIdentity, {
156
+ failureTolerance: 0,
157
+ });
158
+
159
+ expect(keys.hasValidKey).toBe(true);
160
+
161
+ await keys.setKey(await createKeyMaterialFromString('password'), 0);
162
+
163
+ input.write(mockEncryptedRTCEncodedVideoFrame(1));
164
+ await vitest.advanceTimersToNextTimerAsync();
165
+
166
+ expect(output.chunks).toEqual([]);
167
+ expect(keys.hasValidKey).toBe(false);
168
+
169
+ // this should still fail as keys are all marked as invalid
170
+ input.write(mockEncryptedRTCEncodedVideoFrame(0));
171
+ await vitest.advanceTimersToNextTimerAsync();
172
+
173
+ expect(output.chunks).toEqual([]);
174
+ expect(keys.hasValidKey).toBe(false);
175
+ } finally {
176
+ vitest.useRealTimers();
177
+ }
178
+ });
179
+
180
+ it('marks key invalid after too many failures', async () => {
181
+ const { keys, cryptor, input } = prepareParticipantTestDecoder(participantIdentity, {
182
+ failureTolerance: 1,
183
+ });
184
+
185
+ expect(keys.hasValidKey).toBe(true);
186
+
187
+ await keys.setKey(await createKeyMaterialFromString('password'), 0);
188
+
189
+ vitest.spyOn(keys, 'getKeySet');
190
+ vitest.spyOn(keys, 'decryptionFailure');
191
+
192
+ const errorListener = vitest.fn().mockImplementation((e) => {
193
+ console.log('error', e);
194
+ });
195
+ cryptor.on(CryptorEvent.Error, errorListener);
196
+
197
+ input.write(mockEncryptedRTCEncodedVideoFrame(1));
198
+
199
+ await vitest.waitFor(() => expect(keys.decryptionFailure).toHaveBeenCalled());
200
+ expect(errorListener).toHaveBeenCalled();
201
+ expect(keys.decryptionFailure).toHaveBeenCalledTimes(1);
202
+ expect(keys.getKeySet).toHaveBeenCalled();
203
+ expect(keys.getKeySet).toHaveBeenLastCalledWith(1);
204
+ expect(keys.hasValidKey).toBe(true);
205
+
206
+ vitest.clearAllMocks();
207
+
208
+ input.write(mockEncryptedRTCEncodedVideoFrame(1));
209
+
210
+ await vitest.waitFor(() => expect(keys.decryptionFailure).toHaveBeenCalled());
211
+ expect(errorListener).toHaveBeenCalled();
212
+ expect(keys.decryptionFailure).toHaveBeenCalledTimes(1);
213
+ expect(keys.getKeySet).toHaveBeenCalled();
214
+ expect(keys.getKeySet).toHaveBeenLastCalledWith(1);
215
+ expect(keys.hasValidKey).toBe(false);
216
+
217
+ vitest.clearAllMocks();
218
+
219
+ // this should still fail as keys are all marked as invalid
220
+ input.write(mockEncryptedRTCEncodedVideoFrame(0));
221
+
222
+ await vitest.waitFor(() => expect(keys.getKeySet).toHaveBeenCalled());
223
+ // decryptionFailure() isn't called in this case
224
+ expect(keys.getKeySet).toHaveBeenCalled();
225
+ expect(keys.getKeySet).toHaveBeenLastCalledWith(0);
226
+ expect(keys.hasValidKey).toBe(false);
227
+ });
228
+
229
+ it('mark as valid when a new key is set on same index', async () => {
230
+ const { keys, input } = prepareParticipantTestDecoder(participantIdentity, {
231
+ failureTolerance: 0,
232
+ });
233
+
234
+ const material = await createKeyMaterialFromString('password');
235
+ await keys.setKey(material, 0);
236
+
237
+ expect(keys.hasValidKey).toBe(true);
238
+
239
+ input.write(mockEncryptedRTCEncodedVideoFrame(1));
240
+
241
+ expect(keys.hasValidKey).toBe(false);
242
+
243
+ await keys.setKey(material, 0);
244
+
245
+ expect(keys.hasValidKey).toBe(true);
246
+ });
247
+
248
+ it('mark as valid when a new key is set on new index', async () => {
249
+ const { keys, input } = prepareParticipantTestDecoder(participantIdentity, {
250
+ failureTolerance: 0,
251
+ });
252
+
253
+ const material = await createKeyMaterialFromString('password');
254
+ await keys.setKey(material, 0);
255
+
256
+ expect(keys.hasValidKey).toBe(true);
257
+
258
+ input.write(mockEncryptedRTCEncodedVideoFrame(1));
259
+
260
+ expect(keys.hasValidKey).toBe(false);
261
+
262
+ await keys.setKey(material, 1);
263
+
264
+ expect(keys.hasValidKey).toBe(true);
265
+ });
19
266
  });
@@ -6,7 +6,7 @@ import { workerLogger } from '../../logger';
6
6
  import type { VideoCodec } from '../../room/track/options';
7
7
  import { ENCRYPTION_ALGORITHM, IV_LENGTH, UNENCRYPTED_BYTES } from '../constants';
8
8
  import { CryptorError, CryptorErrorReason } from '../errors';
9
- import { CryptorCallbacks, CryptorEvent } from '../events';
9
+ import { type CryptorCallbacks, CryptorEvent } from '../events';
10
10
  import type { DecodeRatchetOptions, KeyProviderOptions, KeySet } from '../types';
11
11
  import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
12
12
  import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
@@ -156,8 +156,8 @@ export class FrameCryptor extends BaseFrameCryptor {
156
156
 
157
157
  setupTransform(
158
158
  operation: 'encode' | 'decode',
159
- readable: ReadableStream,
160
- writable: WritableStream,
159
+ readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
160
+ writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
161
161
  trackId: string,
162
162
  codec?: VideoCodec,
163
163
  ) {
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { KEY_PROVIDER_DEFAULTS } from '../constants';
3
+ import { createKeyMaterialFromString } from '../utils';
4
+ import { ParticipantKeyHandler } from './ParticipantKeyHandler';
5
+
6
+ describe('ParticipantKeyHandler', () => {
7
+ const participantIdentity = 'testParticipant';
8
+
9
+ it('keyringSize must be greater than 0', () => {
10
+ expect(() => {
11
+ new ParticipantKeyHandler(participantIdentity, { ...KEY_PROVIDER_DEFAULTS, keyringSize: 0 });
12
+ }).toThrowError(TypeError);
13
+ });
14
+
15
+ it('keyringSize must be max 256', () => {
16
+ expect(() => {
17
+ new ParticipantKeyHandler(participantIdentity, {
18
+ ...KEY_PROVIDER_DEFAULTS,
19
+ keyringSize: 257,
20
+ });
21
+ }).toThrowError(TypeError);
22
+ });
23
+
24
+ it('get and sets keys at an index', async () => {
25
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, {
26
+ ...KEY_PROVIDER_DEFAULTS,
27
+ keyringSize: 128,
28
+ });
29
+ const materialA = await createKeyMaterialFromString('passwordA');
30
+ const materialB = await createKeyMaterialFromString('passwordB');
31
+ await keyHandler.setKey(materialA, 0);
32
+ expect(keyHandler.getKeySet(0)).toBeDefined();
33
+ expect(keyHandler.getKeySet(0)?.material).toEqual(materialA);
34
+ await keyHandler.setKey(materialB, 0);
35
+ expect(keyHandler.getKeySet(0)?.material).toEqual(materialB);
36
+ });
37
+
38
+ it('marks invalid if more than failureTolerance failures', async () => {
39
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, {
40
+ ...KEY_PROVIDER_DEFAULTS,
41
+ failureTolerance: 2,
42
+ });
43
+ expect(keyHandler.hasValidKey).toBe(true);
44
+
45
+ // 1
46
+ keyHandler.decryptionFailure();
47
+ expect(keyHandler.hasValidKey).toBe(true);
48
+
49
+ // 2
50
+ keyHandler.decryptionFailure();
51
+ expect(keyHandler.hasValidKey).toBe(true);
52
+
53
+ // 3
54
+ keyHandler.decryptionFailure();
55
+ expect(keyHandler.hasValidKey).toBe(false);
56
+ });
57
+
58
+ it('marks valid on encryption success', async () => {
59
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, {
60
+ ...KEY_PROVIDER_DEFAULTS,
61
+ failureTolerance: 0,
62
+ });
63
+
64
+ expect(keyHandler.hasValidKey).toBe(true);
65
+
66
+ keyHandler.decryptionFailure();
67
+
68
+ expect(keyHandler.hasValidKey).toBe(false);
69
+
70
+ keyHandler.decryptionSuccess();
71
+
72
+ expect(keyHandler.hasValidKey).toBe(true);
73
+ });
74
+
75
+ it('marks valid on new key', async () => {
76
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, {
77
+ ...KEY_PROVIDER_DEFAULTS,
78
+ failureTolerance: 0,
79
+ });
80
+
81
+ expect(keyHandler.hasValidKey).toBe(true);
82
+
83
+ keyHandler.decryptionFailure();
84
+
85
+ expect(keyHandler.hasValidKey).toBe(false);
86
+
87
+ await keyHandler.setKey(await createKeyMaterialFromString('passwordA'));
88
+
89
+ expect(keyHandler.hasValidKey).toBe(true);
90
+ });
91
+
92
+ it('updates currentKeyIndex on new key', async () => {
93
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, KEY_PROVIDER_DEFAULTS);
94
+ const material = await createKeyMaterialFromString('password');
95
+
96
+ expect(keyHandler.getCurrentKeyIndex()).toBe(0);
97
+
98
+ // default is zero
99
+ await keyHandler.setKey(material);
100
+ expect(keyHandler.getCurrentKeyIndex()).toBe(0);
101
+
102
+ // should go to next index
103
+ await keyHandler.setKey(material, 1);
104
+ expect(keyHandler.getCurrentKeyIndex()).toBe(1);
105
+
106
+ // should be able to jump ahead
107
+ await keyHandler.setKey(material, 10);
108
+ expect(keyHandler.getCurrentKeyIndex()).toBe(10);
109
+ });
110
+
111
+ it('allows many failures if failureTolerance is -1', async () => {
112
+ const keyHandler = new ParticipantKeyHandler(participantIdentity, {
113
+ ...KEY_PROVIDER_DEFAULTS,
114
+ failureTolerance: -1,
115
+ });
116
+ expect(keyHandler.hasValidKey).toBe(true);
117
+ for (let i = 0; i < 100; i++) {
118
+ keyHandler.decryptionFailure();
119
+ expect(keyHandler.hasValidKey).toBe(true);
120
+ }
121
+ });
122
+ });
@@ -38,7 +38,7 @@ export class ParticipantKeyHandler extends (EventEmitter as new () => TypedEvent
38
38
  constructor(participantIdentity: string, keyProviderOptions: KeyProviderOptions) {
39
39
  super();
40
40
  this.currentKeyIndex = 0;
41
- if (keyProviderOptions.keyringSize < 1 || keyProviderOptions.keyringSize > 255) {
41
+ if (keyProviderOptions.keyringSize < 1 || keyProviderOptions.keyringSize > 256) {
42
42
  throw new TypeError('Keyring size needs to be between 1 and 256');
43
43
  }
44
44
  this.cryptoKeyRing = new Array(keyProviderOptions.keyringSize).fill(undefined);
@@ -1,5 +1,5 @@
1
1
  import { workerLogger } from '../../logger';
2
- import { VideoCodec } from '../../room/track/options';
2
+ import type { VideoCodec } from '../../room/track/options';
3
3
  import { AsyncQueue } from '../../utils/AsyncQueue';
4
4
  import { KEY_PROVIDER_DEFAULTS } from '../constants';
5
5
  import { CryptorErrorReason } from '../errors';
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import type { LiveKitReactNativeInfo } from './room/types';
27
27
  import type { AudioAnalyserOptions } from './room/utils';
28
28
  import {
29
29
  Mutex,
30
+ compareVersions,
30
31
  createAudioAnalyser,
31
32
  getEmptyAudioStreamTrack,
32
33
  getEmptyVideoStreamTrack,
@@ -81,6 +82,7 @@ export {
81
82
  Room,
82
83
  SubscriptionError,
83
84
  TrackPublication,
85
+ compareVersions,
84
86
  createAudioAnalyser,
85
87
  getBrowser,
86
88
  getEmptyAudioStreamTrack,
package/src/room/Room.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  JoinResponse,
8
8
  LeaveRequest,
9
9
  LeaveRequest_Action,
10
+ MetricsBatch,
10
11
  ParticipantInfo,
11
12
  ParticipantInfo_State,
12
13
  ParticipantPermission,
@@ -135,6 +136,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
135
136
  /** reflects the sender encryption status of the local participant */
136
137
  isE2EEEnabled: boolean = false;
137
138
 
139
+ serverInfo?: Partial<ServerInfo>;
140
+
138
141
  private roomInfo?: RoomModel;
139
142
 
140
143
  private sidToIdentity: Map<string, string>;
@@ -609,6 +612,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
609
612
  if (!serverInfo) {
610
613
  serverInfo = { version: joinResponse.serverVersion, region: joinResponse.serverRegion };
611
614
  }
615
+ this.serverInfo = serverInfo;
612
616
 
613
617
  this.log.debug(
614
618
  `connected to Livekit Server ${Object.entries(serverInfo)
@@ -621,11 +625,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
621
625
  },
622
626
  );
623
627
 
624
- if (!joinResponse.serverVersion) {
628
+ if (!serverInfo.version) {
625
629
  throw new UnsupportedServer('unknown server version');
626
630
  }
627
631
 
628
- if (joinResponse.serverVersion === '0.15.1' && this.options.dynacast) {
632
+ if (serverInfo.version === '0.15.1' && this.options.dynacast) {
629
633
  this.log.debug('disabling dynacast due to server version', this.logContext);
630
634
  // dynacast has a bug in 0.15.1, so we cannot use it then
631
635
  roomOptions.dynacast = false;
@@ -1490,14 +1494,17 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1490
1494
  if (!pub || !pub.track) {
1491
1495
  return;
1492
1496
  }
1493
- pub.track.streamState = Track.streamStateFromProto(streamState.state);
1494
- participant.emit(ParticipantEvent.TrackStreamStateChanged, pub, pub.track.streamState);
1495
- this.emitWhenConnected(
1496
- RoomEvent.TrackStreamStateChanged,
1497
- pub,
1498
- pub.track.streamState,
1499
- participant,
1500
- );
1497
+ const newStreamState = Track.streamStateFromProto(streamState.state);
1498
+ if (newStreamState !== pub.track.streamState) {
1499
+ pub.track.streamState = newStreamState;
1500
+ participant.emit(ParticipantEvent.TrackStreamStateChanged, pub, pub.track.streamState);
1501
+ this.emitWhenConnected(
1502
+ RoomEvent.TrackStreamStateChanged,
1503
+ pub,
1504
+ pub.track.streamState,
1505
+ participant,
1506
+ );
1507
+ }
1501
1508
  });
1502
1509
  };
1503
1510
 
@@ -1540,6 +1547,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1540
1547
  this.handleSipDtmf(participant, packet.value.value);
1541
1548
  } else if (packet.value.case === 'chatMessage') {
1542
1549
  this.handleChatMessage(participant, packet.value.value);
1550
+ } else if (packet.value.case === 'metrics') {
1551
+ this.handleMetrics(packet.value.value, participant);
1543
1552
  }
1544
1553
  };
1545
1554
 
@@ -1589,6 +1598,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1589
1598
  this.emit(RoomEvent.ChatMessage, msg, participant);
1590
1599
  };
1591
1600
 
1601
+ private handleMetrics = (metrics: MetricsBatch, participant?: Participant) => {
1602
+ this.emit(RoomEvent.MetricsReceived, metrics, participant);
1603
+ };
1604
+
1592
1605
  private handleAudioPlaybackStarted = () => {
1593
1606
  if (this.canPlaybackAudio) {
1594
1607
  return;
@@ -2289,4 +2302,5 @@ export type RoomEventCallbacks = {
2289
2302
  activeDeviceChanged: (kind: MediaDeviceKind, deviceId: string) => void;
2290
2303
  chatMessage: (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant) => void;
2291
2304
  localTrackSubscribed: (publication: LocalTrackPublication, participant: LocalParticipant) => void;
2305
+ metricsReceived: (metrics: MetricsBatch, participant?: Participant) => void;
2292
2306
  };
@@ -294,7 +294,7 @@ export enum RoomEvent {
294
294
  MediaDevicesError = 'mediaDevicesError',
295
295
 
296
296
  /**
297
- * A participant's permission has changed. Currently only fired on LocalParticipant.
297
+ * A participant's permission has changed.
298
298
  * args: (prevPermissions: [[ParticipantPermission]], participant: [[Participant]])
299
299
  */
300
300
  ParticipantPermissionsChanged = 'participantPermissionsChanged',
@@ -330,6 +330,11 @@ export enum RoomEvent {
330
330
  * fired when the first remote participant has subscribed to the localParticipant's track
331
331
  */
332
332
  LocalTrackSubscribed = 'localTrackSubscribed',
333
+
334
+ /**
335
+ * fired when the client receives connection metrics from other participants
336
+ */
337
+ MetricsReceived = 'metricsReceived',
333
338
  }
334
339
 
335
340
  export enum ParticipantEvent {
@@ -502,7 +507,7 @@ export enum ParticipantEvent {
502
507
  AudioStreamAcquired = 'audioStreamAcquired',
503
508
 
504
509
  /**
505
- * A participant's permission has changed. Currently only fired on LocalParticipant.
510
+ * A participant's permission has changed.
506
511
  * args: (prevPermissions: [[ParticipantPermission]])
507
512
  */
508
513
  ParticipantPermissionsChanged = 'participantPermissionsChanged',
@@ -10,6 +10,7 @@ import {
10
10
  RequestResponse,
11
11
  RequestResponse_Reason,
12
12
  SimulcastCodec,
13
+ SipDTMF,
13
14
  SubscribedQualityUpdate,
14
15
  TrackInfo,
15
16
  TrackUnpublishedResponse,
@@ -1349,6 +1350,27 @@ export default class LocalParticipant extends Participant {
1349
1350
  await this.engine.sendDataPacket(packet, kind);
1350
1351
  }
1351
1352
 
1353
+ /**
1354
+ * Publish SIP DTMF message to the room.
1355
+ *
1356
+ * @param code DTMF code
1357
+ * @param digit DTMF digit
1358
+ */
1359
+ async publishDtmf(code: number, digit: string): Promise<void> {
1360
+ const packet = new DataPacket({
1361
+ kind: DataPacket_Kind.RELIABLE,
1362
+ value: {
1363
+ case: 'sipDtmf',
1364
+ value: new SipDTMF({
1365
+ code: code,
1366
+ digit: digit,
1367
+ }),
1368
+ },
1369
+ });
1370
+
1371
+ await this.engine.sendDataPacket(packet, DataPacket_Kind.RELIABLE);
1372
+ }
1373
+
1352
1374
  async sendChatMessage(text: string): Promise<ChatMessage> {
1353
1375
  const msg = {
1354
1376
  id: crypto.randomUUID(),
@@ -9,7 +9,7 @@ import {
9
9
  } from '@livekit/protocol';
10
10
  import { EventEmitter } from 'events';
11
11
  import type TypedEmitter from 'typed-emitter';
12
- import log, { LoggerNames, StructuredLogger, getLogger } from '../../logger';
12
+ import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
13
13
  import { ParticipantEvent, TrackEvent } from '../events';
14
14
  import LocalAudioTrack from '../track/LocalAudioTrack';
15
15
  import type LocalTrackPublication from '../track/LocalTrackPublication';
@@ -279,7 +279,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
279
279
  permissions.canPublishSources.length !== this.permissions.canPublishSources.length ||
280
280
  permissions.canPublishSources.some(
281
281
  (value, index) => value !== this.permissions?.canPublishSources[index],
282
- );
282
+ ) ||
283
+ permissions.canSubscribeMetrics !== this.permissions?.canSubscribeMetrics;
283
284
  this.permissions = permissions;
284
285
 
285
286
  if (changed) {
@@ -99,7 +99,7 @@ export default class LocalTrackPublication extends TrackPublication {
99
99
  features.add(AudioTrackFeature.TF_STEREO);
100
100
  }
101
101
  if (!this.options?.dtx) {
102
- features.add(AudioTrackFeature.TF_STEREO);
102
+ features.add(AudioTrackFeature.TF_NO_DTX);
103
103
  }
104
104
  if (this.track.enhancedNoiseCancellation) {
105
105
  features.add(AudioTrackFeature.TF_ENHANCED_NOISE_CANCELLATION);
@@ -8,7 +8,7 @@ import {
8
8
  import { EventEmitter } from 'events';
9
9
  import type TypedEventEmitter from 'typed-emitter';
10
10
  import type { SignalClient } from '../../api/SignalClient';
11
- import log, { LoggerNames, StructuredLogger, getLogger } from '../../logger';
11
+ import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
12
12
  import { TrackEvent } from '../events';
13
13
  import type { LoggerOptions } from '../types';
14
14
  import { isFireFox, isSafari, isWeb } from '../utils';
@@ -8,7 +8,7 @@ import {
8
8
  type CreateLocalTracksOptions,
9
9
  type ScreenShareCaptureOptions,
10
10
  type VideoCaptureOptions,
11
- VideoCodec,
11
+ type VideoCodec,
12
12
  videoCodecs,
13
13
  } from './options';
14
14
  import type { AudioTrack } from './types';
package/src/room/utils.ts CHANGED
@@ -9,7 +9,7 @@ import { protocolVersion, version } from '../version';
9
9
  import CriticalTimers from './timers';
10
10
  import type LocalAudioTrack from './track/LocalAudioTrack';
11
11
  import type RemoteAudioTrack from './track/RemoteAudioTrack';
12
- import { VideoCodec, videoCodecs } from './track/options';
12
+ import { type VideoCodec, videoCodecs } from './track/options';
13
13
  import { getNewAudioContext } from './track/utils';
14
14
  import type { ChatMessage, LiveKitReactNativeInfo, TranscriptionSegment } from './types';
15
15
 
package/src/test/mocks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // eslint-disable-next-line import/no-extraneous-dependencies
2
- import { MockedClass, vi } from 'vitest';
2
+ import { type MockedClass, vi } from 'vitest';
3
3
  import { SignalClient } from '../api/SignalClient';
4
4
  import RTCEngine from '../room/RTCEngine';
5
5