livekit-client 2.3.2 → 2.4.0

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 (58) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +8 -6
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +254 -40
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/api/SignalClient.d.ts +5 -2
  10. package/dist/src/api/SignalClient.d.ts.map +1 -1
  11. package/dist/src/e2ee/errors.d.ts +2 -1
  12. package/dist/src/e2ee/errors.d.ts.map +1 -1
  13. package/dist/src/e2ee/index.d.ts +1 -0
  14. package/dist/src/e2ee/index.d.ts.map +1 -1
  15. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  16. package/dist/src/logger.d.ts.map +1 -1
  17. package/dist/src/room/RTCEngine.d.ts +2 -1
  18. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  19. package/dist/src/room/Room.d.ts +1 -0
  20. package/dist/src/room/Room.d.ts.map +1 -1
  21. package/dist/src/room/errors.d.ts +5 -0
  22. package/dist/src/room/errors.d.ts.map +1 -1
  23. package/dist/src/room/events.d.ts +15 -2
  24. package/dist/src/room/events.d.ts.map +1 -1
  25. package/dist/src/room/participant/LocalParticipant.d.ts +14 -6
  26. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  27. package/dist/src/room/participant/Participant.d.ts +8 -0
  28. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  29. package/dist/src/room/timers.d.ts +4 -4
  30. package/dist/src/room/timers.d.ts.map +1 -1
  31. package/dist/src/room/track/utils.d.ts +1 -0
  32. package/dist/src/room/track/utils.d.ts.map +1 -1
  33. package/dist/ts4.2/src/api/SignalClient.d.ts +5 -2
  34. package/dist/ts4.2/src/e2ee/errors.d.ts +2 -1
  35. package/dist/ts4.2/src/e2ee/index.d.ts +1 -0
  36. package/dist/ts4.2/src/room/RTCEngine.d.ts +2 -1
  37. package/dist/ts4.2/src/room/Room.d.ts +1 -0
  38. package/dist/ts4.2/src/room/errors.d.ts +5 -0
  39. package/dist/ts4.2/src/room/events.d.ts +15 -2
  40. package/dist/ts4.2/src/room/participant/LocalParticipant.d.ts +14 -6
  41. package/dist/ts4.2/src/room/participant/Participant.d.ts +8 -0
  42. package/dist/ts4.2/src/room/timers.d.ts +4 -4
  43. package/dist/ts4.2/src/room/track/utils.d.ts +1 -0
  44. package/package.json +2 -2
  45. package/src/api/SignalClient.ts +24 -2
  46. package/src/e2ee/errors.ts +8 -1
  47. package/src/e2ee/index.ts +1 -0
  48. package/src/e2ee/worker/FrameCryptor.ts +15 -3
  49. package/src/logger.ts +4 -3
  50. package/src/room/DeviceManager.ts +1 -1
  51. package/src/room/RTCEngine.ts +3 -0
  52. package/src/room/Room.ts +4 -0
  53. package/src/room/errors.ts +11 -0
  54. package/src/room/events.ts +15 -0
  55. package/src/room/participant/LocalParticipant.ts +92 -10
  56. package/src/room/participant/Participant.ts +23 -0
  57. package/src/room/track/utils.test.ts +35 -1
  58. package/src/room/track/utils.ts +22 -0
@@ -26,6 +26,7 @@ export default class LocalParticipant extends Participant {
26
26
  private roomOptions;
27
27
  private encryptionType;
28
28
  private reconnectFuture?;
29
+ private pendingSignalRequests;
29
30
  /** @internal */
30
31
  constructor(sid: string, identity: string, engine: RTCEngine, options: InternalRoomOptions);
31
32
  get lastCameraError(): Error | undefined;
@@ -40,22 +41,29 @@ export default class LocalParticipant extends Participant {
40
41
  private handleReconnecting;
41
42
  private handleReconnected;
42
43
  private handleDisconnected;
44
+ private handleSignalRequestError;
43
45
  /**
44
46
  * Sets and updates the metadata of the local participant.
45
- * The change does not take immediate effect.
46
- * If successful, a `ParticipantEvent.MetadataChanged` event will be emitted on the local participant.
47
47
  * Note: this requires `canUpdateOwnMetadata` permission.
48
+ * method will throw if the user doesn't have the required permissions
48
49
  * @param metadata
49
50
  */
50
- setMetadata(metadata: string): void;
51
+ setMetadata(metadata: string): Promise<void>;
51
52
  /**
52
53
  * Sets and updates the name of the local participant.
53
- * The change does not take immediate effect.
54
- * If successful, a `ParticipantEvent.ParticipantNameChanged` event will be emitted on the local participant.
55
54
  * Note: this requires `canUpdateOwnMetadata` permission.
55
+ * method will throw if the user doesn't have the required permissions
56
56
  * @param metadata
57
57
  */
58
- setName(name: string): void;
58
+ setName(name: string): Promise<void>;
59
+ /**
60
+ * Set or update participant attributes. It will make updates only to keys that
61
+ * are present in `attributes`, and will not override others.
62
+ * Note: this requires `canUpdateOwnMetadata` permission.
63
+ * @param attributes attributes to update
64
+ */
65
+ setAttributes(attributes: Record<string, string>): Promise<void>;
66
+ private requestMetadataUpdate;
59
67
  /**
60
68
  * Enable or disable a participant's camera track.
61
69
  *
@@ -39,6 +39,7 @@ export default class Participant extends Participant_base {
39
39
  name?: string;
40
40
  /** client metadata, opaque to livekit */
41
41
  metadata?: string;
42
+ private _attributes;
42
43
  lastSpokeAt?: Date | undefined;
43
44
  permissions?: ParticipantPermission;
44
45
  protected _kind: ParticipantKind;
@@ -52,6 +53,8 @@ export default class Participant extends Participant_base {
52
53
  get isEncrypted(): boolean;
53
54
  get isAgent(): boolean;
54
55
  get kind(): ParticipantKind;
56
+ /** participant attributes, similar to metadata, but as a key/value map */
57
+ get attributes(): Readonly<Record<string, string>>;
55
58
  /** @internal */
56
59
  constructor(sid: string, identity: string, name?: string, metadata?: string, loggerOptions?: LoggerOptions, kind?: ParticipantKind);
57
60
  getTrackPublications(): TrackPublication[];
@@ -78,6 +81,10 @@ export default class Participant extends Participant_base {
78
81
  **/
79
82
  private _setMetadata;
80
83
  private _setName;
84
+ /**
85
+ * Updates metadata from server
86
+ **/
87
+ private _setAttributes;
81
88
  /** @internal */
82
89
  setPermissions(permissions: ParticipantPermission): boolean;
83
90
  /** @internal */
@@ -113,5 +120,6 @@ export type ParticipantEventCallbacks = {
113
120
  audioStreamAcquired: () => void;
114
121
  participantPermissionsChanged: (prevPermissions?: ParticipantPermission) => void;
115
122
  trackSubscriptionStatusChanged: (publication: RemoteTrackPublication, status: TrackPublication.SubscriptionStatus) => void;
123
+ attributesChanged: (changedAttributes: Record<string, string>) => void;
116
124
  };
117
125
  //# sourceMappingURL=Participant.d.ts.map
@@ -4,9 +4,9 @@
4
4
  * that the timer fires on time.
5
5
  */
6
6
  export default class CriticalTimers {
7
- static setTimeout: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number;
8
- static setInterval: (handler: TimerHandler, timeout?: number | undefined, ...arguments: any[]) => number;
9
- static clearTimeout: (id: number | undefined) => void;
10
- static clearInterval: (id: number | undefined) => void;
7
+ static setTimeout: (callback: (args: void) => void, ms?: number | undefined) => NodeJS.Timeout;
8
+ static setInterval: (callback: (args: void) => void, ms?: number | undefined) => NodeJS.Timeout;
9
+ static clearTimeout: (timeoutId: string | number | NodeJS.Timeout | undefined) => void;
10
+ static clearInterval: (intervalId: string | number | NodeJS.Timeout | undefined) => void;
11
11
  }
12
12
  //# sourceMappingURL=timers.d.ts.map
@@ -30,4 +30,5 @@ export declare function mimeTypeToVideoCodecString(mimeType: string): "vp8" | "h
30
30
  export declare function getTrackPublicationInfo<T extends TrackPublication>(tracks: T[]): TrackPublishedResponse[];
31
31
  export declare function getLogContextFromTrack(track: Track | TrackPublication): Record<string, unknown>;
32
32
  export declare function supportsSynchronizationSources(): boolean;
33
+ export declare function diffAttributes(oldValues: Record<string, string> | undefined, newValues: Record<string, string> | undefined): Record<string, string>;
33
34
  //# sourceMappingURL=utils.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livekit-client",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "description": "JavaScript/TypeScript client SDK for LiveKit",
5
5
  "main": "./dist/livekit-client.umd.js",
6
6
  "unpkg": "./dist/livekit-client.umd.js",
@@ -36,7 +36,7 @@
36
36
  "author": "David Zhao <david@davidzhao.com>",
37
37
  "license": "Apache-2.0",
38
38
  "dependencies": {
39
- "@livekit/protocol": "1.19.0",
39
+ "@livekit/protocol": "1.19.1",
40
40
  "events": "^3.3.0",
41
41
  "loglevel": "^1.8.0",
42
42
  "sdp-transform": "^2.14.1",
@@ -4,6 +4,7 @@ import {
4
4
  ClientInfo,
5
5
  ConnectionQualityUpdate,
6
6
  DisconnectReason,
7
+ ErrorResponse,
7
8
  JoinResponse,
8
9
  LeaveRequest,
9
10
  LeaveRequest_Action,
@@ -141,6 +142,8 @@ export class SignalClient {
141
142
 
142
143
  onLeave?: (leave: LeaveRequest) => void;
143
144
 
145
+ onErrorResponse?: (error: ErrorResponse) => void;
146
+
144
147
  connectOptions?: ConnectOpts;
145
148
 
146
149
  ws?: WebSocket;
@@ -163,6 +166,11 @@ export class SignalClient {
163
166
  );
164
167
  }
165
168
 
169
+ private getNextRequestId() {
170
+ this._requestId += 1;
171
+ return this._requestId;
172
+ }
173
+
166
174
  private options?: SignalOptions;
167
175
 
168
176
  private pingTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -183,6 +191,8 @@ export class SignalClient {
183
191
 
184
192
  private loggerContextCb?: LoggerOptions['loggerContextCb'];
185
193
 
194
+ private _requestId = 0;
195
+
186
196
  constructor(useJSON: boolean = false, loggerOptions: LoggerOptions = {}) {
187
197
  this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Signal);
188
198
  this.loggerContextCb = loggerOptions.loggerContextCb;
@@ -511,14 +521,22 @@ export class SignalClient {
511
521
  });
512
522
  }
513
523
 
514
- sendUpdateLocalMetadata(metadata: string, name: string) {
515
- return this.sendRequest({
524
+ async sendUpdateLocalMetadata(
525
+ metadata: string,
526
+ name: string,
527
+ attributes: Record<string, string> = {},
528
+ ) {
529
+ const requestId = this.getNextRequestId();
530
+ await this.sendRequest({
516
531
  case: 'updateMetadata',
517
532
  value: new UpdateParticipantMetadata({
533
+ requestId,
518
534
  metadata,
519
535
  name,
536
+ attributes,
520
537
  }),
521
538
  });
539
+ return requestId;
522
540
  }
523
541
 
524
542
  sendUpdateTrackSettings(settings: UpdateTrackSettings) {
@@ -721,6 +739,10 @@ export class SignalClient {
721
739
  this.rtt = Date.now() - Number.parseInt(msg.value.lastPingTimestamp.toString());
722
740
  this.resetPingTimeout();
723
741
  pingHandled = true;
742
+ } else if (msg.case === 'errorResponse') {
743
+ if (this.onErrorResponse) {
744
+ this.onErrorResponse(msg.value);
745
+ }
724
746
  } else {
725
747
  this.log.debug('unsupported message', { ...this.logContext, msgCase: msg.case });
726
748
  }
@@ -9,8 +9,15 @@ export enum CryptorErrorReason {
9
9
  export class CryptorError extends LivekitError {
10
10
  reason: CryptorErrorReason;
11
11
 
12
- constructor(message?: string, reason: CryptorErrorReason = CryptorErrorReason.InternalError) {
12
+ participantIdentity?: string;
13
+
14
+ constructor(
15
+ message?: string,
16
+ reason: CryptorErrorReason = CryptorErrorReason.InternalError,
17
+ participantIdentity?: string,
18
+ ) {
13
19
  super(40, message);
14
20
  this.reason = reason;
21
+ this.participantIdentity = participantIdentity;
15
22
  }
16
23
  }
package/src/e2ee/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './KeyProvider';
2
2
  export * from './utils';
3
3
  export * from './types';
4
4
  export * from './events';
5
+ export * from './errors';
@@ -183,7 +183,12 @@ export class FrameCryptor extends BaseFrameCryptor {
183
183
  .pipeTo(writable)
184
184
  .catch((e) => {
185
185
  workerLogger.warn(e);
186
- this.emit(CryptorEvent.Error, e instanceof CryptorError ? e : new CryptorError(e.message));
186
+ this.emit(
187
+ CryptorEvent.Error,
188
+ e instanceof CryptorError
189
+ ? e
190
+ : new CryptorError(e.message, undefined, this.participantIdentity),
191
+ );
187
192
  });
188
193
  this.trackId = trackId;
189
194
  }
@@ -294,10 +299,14 @@ export class FrameCryptor extends BaseFrameCryptor {
294
299
  workerLogger.error(e);
295
300
  }
296
301
  } else {
297
- workerLogger.debug('failed to decrypt, emitting error', this.logContext);
302
+ workerLogger.debug('failed to encrypt, emitting error', this.logContext);
298
303
  this.emit(
299
304
  CryptorEvent.Error,
300
- new CryptorError(`encryption key missing for encoding`, CryptorErrorReason.MissingKey),
305
+ new CryptorError(
306
+ `encryption key missing for encoding`,
307
+ CryptorErrorReason.MissingKey,
308
+ this.participantIdentity,
309
+ ),
301
310
  );
302
311
  }
303
312
  }
@@ -367,6 +376,7 @@ export class FrameCryptor extends BaseFrameCryptor {
367
376
  new CryptorError(
368
377
  `missing key at index ${keyIndex} for participant ${this.participantIdentity}`,
369
378
  CryptorErrorReason.MissingKey,
379
+ this.participantIdentity,
370
380
  ),
371
381
  );
372
382
  }
@@ -487,12 +497,14 @@ export class FrameCryptor extends BaseFrameCryptor {
487
497
  throw new CryptorError(
488
498
  `valid key missing for participant ${this.participantIdentity}`,
489
499
  CryptorErrorReason.InvalidKey,
500
+ this.participantIdentity,
490
501
  );
491
502
  }
492
503
  } else {
493
504
  throw new CryptorError(
494
505
  `Decryption failed: ${error.message}`,
495
506
  CryptorErrorReason.InvalidKey,
507
+ this.participantIdentity,
496
508
  );
497
509
  }
498
510
  }
package/src/logger.ts CHANGED
@@ -54,9 +54,10 @@ export function getLogger(name: string) {
54
54
  export function setLogLevel(level: LogLevel | LogLevelString, loggerName?: LoggerNames) {
55
55
  if (loggerName) {
56
56
  log.getLogger(loggerName).setLevel(level);
57
- }
58
- for (const logger of livekitLoggers) {
59
- logger.setLevel(level);
57
+ } else {
58
+ for (const logger of livekitLoggers) {
59
+ logger.setLevel(level);
60
+ }
60
61
  }
61
62
  }
62
63
 
@@ -41,7 +41,7 @@ export default class DeviceManager {
41
41
  !(isSafari() && this.hasDeviceInUse(kind))
42
42
  ) {
43
43
  const isDummyDeviceOrEmpty =
44
- devices.length === 0 ||
44
+ devices.filter((d) => d.kind === kind).length === 0 ||
45
45
  devices.some((device) => {
46
46
  const noLabel = device.label === '';
47
47
  const isRelevant = kind ? device.kind === kind : true;
@@ -7,6 +7,7 @@ import {
7
7
  DataPacket,
8
8
  DataPacket_Kind,
9
9
  DisconnectReason,
10
+ ErrorResponse,
10
11
  type JoinResponse,
11
12
  type LeaveRequest,
12
13
  LeaveRequest_Action,
@@ -193,6 +194,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
193
194
  this.emit(EngineEvent.SubscriptionPermissionUpdate, update);
194
195
  this.client.onSpeakersChanged = (update) => this.emit(EngineEvent.SpeakersChanged, update);
195
196
  this.client.onStreamStateUpdate = (update) => this.emit(EngineEvent.StreamStateChanged, update);
197
+ this.client.onErrorResponse = (error) => this.emit(EngineEvent.SignalRequestError, error);
196
198
  }
197
199
 
198
200
  /** @internal */
@@ -1412,6 +1414,7 @@ export type EngineEventCallbacks = {
1412
1414
  localTrackUnpublished: (unpublishedResponse: TrackUnpublishedResponse) => void;
1413
1415
  remoteMute: (trackSid: string, muted: boolean) => void;
1414
1416
  offline: () => void;
1417
+ signalRequestError: (error: ErrorResponse) => void;
1415
1418
  };
1416
1419
 
1417
1420
  function supportOptionalDatachannel(protocol: number | undefined): boolean {
package/src/room/Room.ts CHANGED
@@ -2131,6 +2131,10 @@ export type RoomEventCallbacks = {
2131
2131
  prevPermissions: ParticipantPermission | undefined,
2132
2132
  participant: RemoteParticipant | LocalParticipant,
2133
2133
  ) => void;
2134
+ participantAttributesChanged: (
2135
+ changedAttributes: Record<string, string>,
2136
+ participant: RemoteParticipant | LocalParticipant,
2137
+ ) => void;
2134
2138
  activeSpeakersChanged: (speakers: Array<Participant>) => void;
2135
2139
  roomMetadataChanged: (metadata: string) => void;
2136
2140
  dataReceived: (
@@ -1,3 +1,5 @@
1
+ import { ErrorResponse_Reason } from '@livekit/protocol';
2
+
1
3
  export class LivekitError extends Error {
2
4
  code: number;
3
5
 
@@ -63,6 +65,15 @@ export class PublishDataError extends LivekitError {
63
65
  }
64
66
  }
65
67
 
68
+ export class SignalRequestError extends LivekitError {
69
+ reason: ErrorResponse_Reason;
70
+
71
+ constructor(message: string, reason: ErrorResponse_Reason = ErrorResponse_Reason.UNKNOWN) {
72
+ super(15, message);
73
+ this.reason = reason;
74
+ }
75
+ }
76
+
66
77
  export enum MediaDeviceFailure {
67
78
  // user rejected permissions
68
79
  PermissionDenied = 'PermissionDenied',
@@ -185,6 +185,13 @@ export enum RoomEvent {
185
185
  */
186
186
  ParticipantNameChanged = 'participantNameChanged',
187
187
 
188
+ /**
189
+ * Participant attributes is an app-specific key value state to be pushed to
190
+ * all users.
191
+ * When a participant's attributes changed, this event will be emitted with the changed attributes and the participant
192
+ */
193
+ ParticipantAttributesChanged = 'participantAttributesChanged',
194
+
188
195
  /**
189
196
  * Room metadata is a simple way for app-specific state to be pushed to
190
197
  * all users.
@@ -495,6 +502,13 @@ export enum ParticipantEvent {
495
502
 
496
503
  /** @internal */
497
504
  PCTrackAdded = 'pcTrackAdded',
505
+
506
+ /**
507
+ * Participant attributes is an app-specific key value state to be pushed to
508
+ * all users.
509
+ * When a participant's attributes changed, this event will be emitted with the changed attributes
510
+ */
511
+ AttributesChanged = 'attributesChanged',
498
512
  }
499
513
 
500
514
  /** @internal */
@@ -525,6 +539,7 @@ export enum EngineEvent {
525
539
  SubscribedQualityUpdate = 'subscribedQualityUpdate',
526
540
  LocalTrackUnpublished = 'localTrackUnpublished',
527
541
  Offline = 'offline',
542
+ SignalRequestError = 'signalRequestError',
528
543
  }
529
544
 
530
545
  export enum TrackEvent {
@@ -3,6 +3,7 @@ import {
3
3
  DataPacket,
4
4
  DataPacket_Kind,
5
5
  Encryption_Type,
6
+ ErrorResponse,
6
7
  ParticipantInfo,
7
8
  ParticipantPermission,
8
9
  SimulcastCodec,
@@ -14,7 +15,13 @@ import type { InternalRoomOptions } from '../../options';
14
15
  import { PCTransportState } from '../PCTransportManager';
15
16
  import type RTCEngine from '../RTCEngine';
16
17
  import { defaultVideoCodec } from '../defaults';
17
- import { DeviceUnsupportedError, TrackInvalidError, UnexpectedConnectionState } from '../errors';
18
+ import {
19
+ DeviceUnsupportedError,
20
+ LivekitError,
21
+ SignalRequestError,
22
+ TrackInvalidError,
23
+ UnexpectedConnectionState,
24
+ } from '../errors';
18
25
  import { EngineEvent, ParticipantEvent, TrackEvent } from '../events';
19
26
  import LocalAudioTrack from '../track/LocalAudioTrack';
20
27
  import LocalTrack from '../track/LocalTrack';
@@ -46,6 +53,7 @@ import {
46
53
  isSVCCodec,
47
54
  isSafari17,
48
55
  isWeb,
56
+ sleep,
49
57
  supportsAV1,
50
58
  supportsVP9,
51
59
  } from '../utils';
@@ -92,6 +100,15 @@ export default class LocalParticipant extends Participant {
92
100
 
93
101
  private reconnectFuture?: Future<void>;
94
102
 
103
+ private pendingSignalRequests: Map<
104
+ number,
105
+ {
106
+ resolve: (arg: any) => void;
107
+ reject: (reason: LivekitError) => void;
108
+ values: Partial<Record<keyof LocalParticipant, any>>;
109
+ }
110
+ >;
111
+
95
112
  /** @internal */
96
113
  constructor(sid: string, identity: string, engine: RTCEngine, options: InternalRoomOptions) {
97
114
  super(sid, identity, undefined, undefined, {
@@ -105,6 +122,7 @@ export default class LocalParticipant extends Participant {
105
122
  this.roomOptions = options;
106
123
  this.setupEngine(engine);
107
124
  this.activeDeviceMap = new Map();
125
+ this.pendingSignalRequests = new Map();
108
126
  }
109
127
 
110
128
  get lastCameraError(): Error | undefined {
@@ -158,7 +176,8 @@ export default class LocalParticipant extends Participant {
158
176
  .on(EngineEvent.Resuming, this.handleReconnecting)
159
177
  .on(EngineEvent.LocalTrackUnpublished, this.handleLocalTrackUnpublished)
160
178
  .on(EngineEvent.SubscribedQualityUpdate, this.handleSubscribedQualityUpdate)
161
- .on(EngineEvent.Disconnected, this.handleDisconnected);
179
+ .on(EngineEvent.Disconnected, this.handleDisconnected)
180
+ .on(EngineEvent.SignalRequestError, this.handleSignalRequestError);
162
181
  }
163
182
 
164
183
  private handleReconnecting = () => {
@@ -181,26 +200,89 @@ export default class LocalParticipant extends Participant {
181
200
  }
182
201
  };
183
202
 
203
+ private handleSignalRequestError = (error: ErrorResponse) => {
204
+ const { requestId, reason, message } = error;
205
+ const failedRequest = this.pendingSignalRequests.get(requestId);
206
+ if (failedRequest) {
207
+ failedRequest.reject(new SignalRequestError(message, reason));
208
+ this.pendingSignalRequests.delete(requestId);
209
+ }
210
+ };
211
+
184
212
  /**
185
213
  * Sets and updates the metadata of the local participant.
186
- * The change does not take immediate effect.
187
- * If successful, a `ParticipantEvent.MetadataChanged` event will be emitted on the local participant.
188
214
  * Note: this requires `canUpdateOwnMetadata` permission.
215
+ * method will throw if the user doesn't have the required permissions
189
216
  * @param metadata
190
217
  */
191
- setMetadata(metadata: string): void {
192
- this.engine.client.sendUpdateLocalMetadata(metadata, this.name ?? '');
218
+ async setMetadata(metadata: string): Promise<void> {
219
+ await this.requestMetadataUpdate({ metadata });
193
220
  }
194
221
 
195
222
  /**
196
223
  * Sets and updates the name of the local participant.
197
- * The change does not take immediate effect.
198
- * If successful, a `ParticipantEvent.ParticipantNameChanged` event will be emitted on the local participant.
199
224
  * Note: this requires `canUpdateOwnMetadata` permission.
225
+ * method will throw if the user doesn't have the required permissions
200
226
  * @param metadata
201
227
  */
202
- setName(name: string): void {
203
- this.engine.client.sendUpdateLocalMetadata(this.metadata ?? '', name);
228
+ async setName(name: string): Promise<void> {
229
+ await this.requestMetadataUpdate({ name });
230
+ }
231
+
232
+ /**
233
+ * Set or update participant attributes. It will make updates only to keys that
234
+ * are present in `attributes`, and will not override others.
235
+ * Note: this requires `canUpdateOwnMetadata` permission.
236
+ * @param attributes attributes to update
237
+ */
238
+ async setAttributes(attributes: Record<string, string>) {
239
+ await this.requestMetadataUpdate({ attributes });
240
+ }
241
+
242
+ private async requestMetadataUpdate({
243
+ metadata,
244
+ name,
245
+ attributes,
246
+ }: {
247
+ metadata?: string;
248
+ name?: string;
249
+ attributes?: Record<string, string>;
250
+ }) {
251
+ return new Promise<void>(async (resolve, reject) => {
252
+ try {
253
+ let isRejected = false;
254
+ const requestId = await this.engine.client.sendUpdateLocalMetadata(
255
+ metadata ?? this.metadata ?? '',
256
+ name ?? this.name ?? '',
257
+ attributes,
258
+ );
259
+ const startTime = performance.now();
260
+ this.pendingSignalRequests.set(requestId, {
261
+ resolve,
262
+ reject: (error: LivekitError) => {
263
+ reject(error);
264
+ isRejected = true;
265
+ },
266
+ values: { name, metadata, attributes },
267
+ });
268
+ while (performance.now() - startTime < 5_000 && !isRejected) {
269
+ if (
270
+ (!name || this.name === name) &&
271
+ (!metadata || this.metadata === metadata) &&
272
+ (!attributes ||
273
+ Object.entries(attributes).every(([key, value]) => this.attributes[key] === value))
274
+ ) {
275
+ this.pendingSignalRequests.delete(requestId);
276
+ resolve();
277
+ return;
278
+ }
279
+ await sleep(50);
280
+ }
281
+ reject(new SignalRequestError('Request to update local metadata timed out'));
282
+ } catch (e: any) {
283
+ if (e instanceof Error) reject(e);
284
+ }
285
+ });
204
286
  }
205
287
 
206
288
  /**
@@ -18,6 +18,7 @@ import type RemoteTrack from '../track/RemoteTrack';
18
18
  import type RemoteTrackPublication from '../track/RemoteTrackPublication';
19
19
  import { Track } from '../track/Track';
20
20
  import type { TrackPublication } from '../track/TrackPublication';
21
+ import { diffAttributes } from '../track/utils';
21
22
  import type { LoggerOptions, TranscriptionSegment } from '../types';
22
23
 
23
24
  export enum ConnectionQuality {
@@ -77,6 +78,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
77
78
  /** client metadata, opaque to livekit */
78
79
  metadata?: string;
79
80
 
81
+ private _attributes: Record<string, string>;
82
+
80
83
  lastSpokeAt?: Date | undefined;
81
84
 
82
85
  permissions?: ParticipantPermission;
@@ -112,6 +115,11 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
112
115
  return this._kind;
113
116
  }
114
117
 
118
+ /** participant attributes, similar to metadata, but as a key/value map */
119
+ get attributes(): Readonly<Record<string, string>> {
120
+ return Object.freeze({ ...this._attributes });
121
+ }
122
+
115
123
  /** @internal */
116
124
  constructor(
117
125
  sid: string,
@@ -135,6 +143,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
135
143
  this.videoTrackPublications = new Map();
136
144
  this.trackPublications = new Map();
137
145
  this._kind = kind;
146
+ this._attributes = {};
138
147
  }
139
148
 
140
149
  getTrackPublications(): TrackPublication[] {
@@ -214,6 +223,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
214
223
  this.sid = info.sid;
215
224
  this._setName(info.name);
216
225
  this._setMetadata(info.metadata);
226
+ this._setAttributes(info.attributes);
217
227
  if (info.permission) {
218
228
  this.setPermissions(info.permission);
219
229
  }
@@ -245,6 +255,18 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
245
255
  }
246
256
  }
247
257
 
258
+ /**
259
+ * Updates metadata from server
260
+ **/
261
+ private _setAttributes(attributes: Record<string, string>) {
262
+ const diff = diffAttributes(attributes, this.attributes);
263
+ this._attributes = attributes;
264
+
265
+ if (Object.keys(diff).length > 0) {
266
+ this.emit(ParticipantEvent.AttributesChanged, diff);
267
+ }
268
+ }
269
+
248
270
  /** @internal */
249
271
  setPermissions(permissions: ParticipantPermission): boolean {
250
272
  const prevPermissions = this.permissions;
@@ -363,4 +385,5 @@ export type ParticipantEventCallbacks = {
363
385
  publication: RemoteTrackPublication,
364
386
  status: TrackPublication.SubscriptionStatus,
365
387
  ) => void;
388
+ attributesChanged: (changedAttributes: Record<string, string>) => void;
366
389
  };
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { AudioCaptureOptions, VideoCaptureOptions, VideoPresets } from './options';
3
- import { constraintsForOptions, mergeDefaultOptions } from './utils';
3
+ import { constraintsForOptions, diffAttributes, mergeDefaultOptions } from './utils';
4
4
 
5
5
  describe('mergeDefaultOptions', () => {
6
6
  const audioDefaults: AudioCaptureOptions = {
@@ -109,3 +109,37 @@ describe('constraintsForOptions', () => {
109
109
  expect(videoOpts.aspectRatio).toEqual(VideoPresets.h720.resolution.aspectRatio);
110
110
  });
111
111
  });
112
+
113
+ describe('diffAttributes', () => {
114
+ it('detects changed values', () => {
115
+ const oldValues: Record<string, string> = { a: 'value', b: 'initial', c: 'value' };
116
+ const newValues: Record<string, string> = { a: 'value', b: 'updated', c: 'value' };
117
+
118
+ const diff = diffAttributes(oldValues, newValues);
119
+ expect(Object.keys(diff).length).toBe(1);
120
+ expect(diff.b).toBe('updated');
121
+ });
122
+ it('detects new values', () => {
123
+ const newValues: Record<string, string> = { a: 'value', b: 'value', c: 'value' };
124
+ const oldValues: Record<string, string> = { a: 'value', b: 'value' };
125
+
126
+ const diff = diffAttributes(oldValues, newValues);
127
+ expect(Object.keys(diff).length).toBe(1);
128
+ expect(diff.c).toBe('value');
129
+ });
130
+ it('detects deleted values as empty strings', () => {
131
+ const newValues: Record<string, string> = { a: 'value', b: 'value' };
132
+ const oldValues: Record<string, string> = { a: 'value', b: 'value', c: 'value' };
133
+
134
+ const diff = diffAttributes(oldValues, newValues);
135
+ expect(Object.keys(diff).length).toBe(1);
136
+ expect(diff.c).toBe('');
137
+ });
138
+ it('compares with undefined values', () => {
139
+ const newValues: Record<string, string> = { a: 'value', b: 'value' };
140
+
141
+ const diff = diffAttributes(undefined, newValues);
142
+ expect(Object.keys(diff).length).toBe(2);
143
+ expect(diff.a).toBe('value');
144
+ });
145
+ });