diff --git a/.changeset/heavy-buses-fly.md b/.changeset/heavy-buses-fly.md new file mode 100644 index 0000000000..4811c4bd68 --- /dev/null +++ b/.changeset/heavy-buses-fly.md @@ -0,0 +1,5 @@ +--- +"livekit-client": minor +--- + +Add support for participant attributes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968ee34953..94560b3aa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3232,8 +3232,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.6.0-dev.20240708: - resolution: {integrity: sha512-znYUhYs3kjemvesSDNvtU17+eUiQnX4RNegVbFdWQ54mXzYrg+0nI2isOO/RQCeGHFuO1M6uR/2/bC+mcDnAVQ==} + typescript@5.6.0-dev.20240702: + resolution: {integrity: sha512-hbRazJD/2++Y7fBv6NZtCyoMfoVYmuDlGs+jqdWicZfXP6Fp8q9FDFnuvYpKNq1fXwBrGIUXEZlXssvXIWy/FQ==} engines: {node: '>=14.17'} hasBin: true @@ -5435,7 +5435,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 5.6.0-dev.20240708 + typescript: 5.6.0-dev.20240702 electron-to-chromium@1.4.724: {} @@ -6978,7 +6978,7 @@ snapshots: typescript@5.5.3: {} - typescript@5.6.0-dev.20240708: {} + typescript@5.6.0-dev.20240702: {} uc.micro@2.1.0: {} diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index 4dac4f886f..53a840bfe1 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -511,12 +511,13 @@ export class SignalClient { }); } - sendUpdateLocalMetadata(metadata: string, name: string) { + sendUpdateLocalMetadata(metadata: string, name: string, attributes: Record = {}) { return this.sendRequest({ case: 'updateMetadata', value: new UpdateParticipantMetadata({ metadata, name, + attributes, }), }); } diff --git a/src/room/Room.ts b/src/room/Room.ts index a6c2437de3..3c826a3c9f 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -2131,6 +2131,10 @@ export type RoomEventCallbacks = { prevPermissions: ParticipantPermission | undefined, participant: RemoteParticipant | LocalParticipant, ) => void; + participantAttributesChanged: ( + changedAttributes: Record, + participant: RemoteParticipant | LocalParticipant, + ) => void; activeSpeakersChanged: (speakers: Array) => void; roomMetadataChanged: (metadata: string) => void; dataReceived: ( diff --git a/src/room/events.ts b/src/room/events.ts index 03062d2f0f..703226c0e4 100644 --- a/src/room/events.ts +++ b/src/room/events.ts @@ -185,6 +185,13 @@ export enum RoomEvent { */ ParticipantNameChanged = 'participantNameChanged', + /** + * Participant attributes is an app-specific key value state to be pushed to + * all users. + * When a participant's attributes changed, this event will be emitted with the changed attributes and the participant + */ + ParticipantAttributesChanged = 'participantAttributesChanged', + /** * Room metadata is a simple way for app-specific state to be pushed to * all users. @@ -495,6 +502,13 @@ export enum ParticipantEvent { /** @internal */ PCTrackAdded = 'pcTrackAdded', + + /** + * Participant attributes is an app-specific key value state to be pushed to + * all users. + * When a participant's attributes changed, this event will be emitted with the changed attributes + */ + AttributesChanged = 'attributesChanged', } /** @internal */ diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 76129581a2..3de1c9550e 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -203,6 +203,14 @@ export default class LocalParticipant extends Participant { this.engine.client.sendUpdateLocalMetadata(this.metadata ?? '', name); } + async setAttributes(attributes: Record) { + await this.engine.client.sendUpdateLocalMetadata( + this.metadata ?? '', + this.name ?? '', + attributes, + ); + } + /** * Enable or disable a participant's camera track. * diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index a0b528c0f1..3e6ce4e751 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -18,6 +18,7 @@ import type RemoteTrack from '../track/RemoteTrack'; import type RemoteTrackPublication from '../track/RemoteTrackPublication'; import { Track } from '../track/Track'; import type { TrackPublication } from '../track/TrackPublication'; +import { diffAttributes } from '../track/utils'; import type { LoggerOptions, TranscriptionSegment } from '../types'; export enum ConnectionQuality { @@ -77,6 +78,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter /** client metadata, opaque to livekit */ metadata?: string; + private _attributes: Record; + lastSpokeAt?: Date | undefined; permissions?: ParticipantPermission; @@ -112,6 +115,11 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter return this._kind; } + /** participant attributes, similar to metadata, but as a key/value map */ + get attributes(): Readonly> { + return Object.freeze({ ...this._attributes }); + } + /** @internal */ constructor( sid: string, @@ -135,6 +143,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter this.videoTrackPublications = new Map(); this.trackPublications = new Map(); this._kind = kind; + this._attributes = {}; } getTrackPublications(): TrackPublication[] { @@ -214,6 +223,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter this.sid = info.sid; this._setName(info.name); this._setMetadata(info.metadata); + this._setAttributes(info.attributes); if (info.permission) { this.setPermissions(info.permission); } @@ -245,6 +255,18 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter } } + /** + * Updates metadata from server + **/ + private _setAttributes(attributes: Record) { + const diff = diffAttributes(attributes, this.attributes); + this._attributes = attributes; + + if (Object.keys(diff).length > 0) { + this.emit(ParticipantEvent.AttributesChanged, diff); + } + } + /** @internal */ setPermissions(permissions: ParticipantPermission): boolean { const prevPermissions = this.permissions; @@ -363,4 +385,5 @@ export type ParticipantEventCallbacks = { publication: RemoteTrackPublication, status: TrackPublication.SubscriptionStatus, ) => void; + attributesChanged: (changedAttributes: Record) => void; }; diff --git a/src/room/track/utils.test.ts b/src/room/track/utils.test.ts index 33ade7504e..40613a5635 100644 --- a/src/room/track/utils.test.ts +++ b/src/room/track/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { AudioCaptureOptions, VideoCaptureOptions, VideoPresets } from './options'; -import { constraintsForOptions, mergeDefaultOptions } from './utils'; +import { constraintsForOptions, diffAttributes, mergeDefaultOptions } from './utils'; describe('mergeDefaultOptions', () => { const audioDefaults: AudioCaptureOptions = { @@ -109,3 +109,30 @@ describe('constraintsForOptions', () => { expect(videoOpts.aspectRatio).toEqual(VideoPresets.h720.resolution.aspectRatio); }); }); + +describe('diffAttributes', () => { + it('detects changed values', () => { + const oldValues: Record = { a: 'value', b: 'initial', c: 'value' }; + const newValues: Record = { a: 'value', b: 'updated', c: 'value' }; + + const diff = diffAttributes(oldValues, newValues); + expect(Object.keys(diff).length).toBe(1); + expect(diff.b).toBe('updated'); + }); + it('detects new values', () => { + const newValues: Record = { a: 'value', b: 'value', c: 'value' }; + const oldValues: Record = { a: 'value', b: 'value' }; + + const diff = diffAttributes(oldValues, newValues); + expect(Object.keys(diff).length).toBe(1); + expect(diff.c).toBe('value'); + }); + it('detects deleted values as empty strings', () => { + const newValues: Record = { a: 'value', b: 'value' }; + const oldValues: Record = { a: 'value', b: 'value', c: 'value' }; + + const diff = diffAttributes(oldValues, newValues); + expect(Object.keys(diff).length).toBe(1); + expect(diff.c).toBe(''); + }); +}); diff --git a/src/room/track/utils.ts b/src/room/track/utils.ts index 0353c24932..8b53ea3add 100644 --- a/src/room/track/utils.ts +++ b/src/room/track/utils.ts @@ -243,3 +243,19 @@ export function getLogContextFromTrack(track: Track | TrackPublication): Record< export function supportsSynchronizationSources(): boolean { return typeof RTCRtpReceiver !== 'undefined' && 'getSynchronizationSources' in RTCRtpReceiver; } + +export function diffAttributes( + oldValues: Record, + newValues: Record, +) { + const allKeys = [...Object.keys(newValues), ...Object.keys(oldValues)]; + const diff: Record = {}; + + for (const key of allKeys) { + if (oldValues[key] !== newValues[key]) { + diff[key] = newValues[key] ?? ''; + } + } + + return diff; +}