livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
444 lines (381 loc) • 13.6 kB
text/typescript
import {
DataPacket_Kind,
Encryption_Type,
ParticipantInfo,
ParticipantInfo_State,
ParticipantInfo_Kind as ParticipantKind,
ParticipantPermission,
ConnectionQuality as ProtoQuality,
type SipDTMF,
SubscriptionError,
} from '@livekit/protocol';
import { EventEmitter } from 'events';
import type TypedEmitter from 'typed-emitter';
import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
import { ParticipantEvent, TrackEvent } from '../events';
import type LocalTrackPublication from '../track/LocalTrackPublication';
import type LocalVideoTrack from '../track/LocalVideoTrack';
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 { ChatMessage, LoggerOptions, TranscriptionSegment } from '../types';
import { Future, isAudioTrack } from '../utils';
export enum ConnectionQuality {
Excellent = 'excellent',
Good = 'good',
Poor = 'poor',
/**
* Indicates that a participant has temporarily (or permanently) lost connection to LiveKit.
* For permanent disconnection a `ParticipantDisconnected` event will be emitted after a timeout
*/
Lost = 'lost',
Unknown = 'unknown',
}
function qualityFromProto(q: ProtoQuality): ConnectionQuality {
switch (q) {
case ProtoQuality.EXCELLENT:
return ConnectionQuality.Excellent;
case ProtoQuality.GOOD:
return ConnectionQuality.Good;
case ProtoQuality.POOR:
return ConnectionQuality.Poor;
case ProtoQuality.LOST:
return ConnectionQuality.Lost;
default:
return ConnectionQuality.Unknown;
}
}
export { ParticipantKind };
export default class Participant extends (EventEmitter as new () => TypedEmitter<ParticipantEventCallbacks>) {
protected participantInfo?: ParticipantInfo;
audioTrackPublications: Map<string, TrackPublication>;
videoTrackPublications: Map<string, TrackPublication>;
/** map of track sid => all published tracks */
trackPublications: Map<string, TrackPublication>;
/** audio level between 0-1.0, 1 being loudest, 0 being softest */
audioLevel: number = 0;
/** if participant is currently speaking */
isSpeaking: boolean = false;
/** server assigned unique id */
sid: string;
/** client assigned identity, encoded in JWT token */
identity: string;
/** client assigned display name, encoded in JWT token */
name?: string;
/** client metadata, opaque to livekit */
metadata?: string;
private _attributes: Record<string, string>;
lastSpokeAt?: Date | undefined;
permissions?: ParticipantPermission;
protected _kind: ParticipantKind;
private _connectionQuality: ConnectionQuality = ConnectionQuality.Unknown;
protected audioContext?: AudioContext;
protected log: StructuredLogger = log;
protected loggerOptions?: LoggerOptions;
protected activeFuture?: Future<void, Error>;
protected get logContext() {
return {
...this.loggerOptions?.loggerContextCb?.(),
};
}
get isEncrypted() {
return (
this.trackPublications.size > 0 &&
Array.from(this.trackPublications.values()).every((tr) => tr.isEncrypted)
);
}
get isAgent() {
return this.permissions?.agent || this.kind === ParticipantKind.AGENT;
}
get isActive() {
return this.participantInfo?.state === ParticipantInfo_State.ACTIVE;
}
get kind() {
return this._kind;
}
/** participant attributes, similar to metadata, but as a key/value map */
get attributes(): Readonly<Record<string, string>> {
return Object.freeze({ ...this._attributes });
}
/** @internal */
constructor(
sid: string,
identity: string,
name?: string,
metadata?: string,
attributes?: Record<string, string>,
loggerOptions?: LoggerOptions,
kind: ParticipantKind = ParticipantKind.STANDARD,
) {
super();
this.log = getLogger(loggerOptions?.loggerName ?? LoggerNames.Participant);
this.loggerOptions = loggerOptions;
this.setMaxListeners(100);
this.sid = sid;
this.identity = identity;
this.name = name;
this.metadata = metadata;
this.audioTrackPublications = new Map();
this.videoTrackPublications = new Map();
this.trackPublications = new Map();
this._kind = kind;
this._attributes = attributes ?? {};
}
getTrackPublications(): TrackPublication[] {
return Array.from(this.trackPublications.values());
}
/**
* Finds the first track that matches the source filter, for example, getting
* the user's camera track with getTrackBySource(Track.Source.Camera).
*/
getTrackPublication(source: Track.Source): TrackPublication | undefined {
for (const [, pub] of this.trackPublications) {
if (pub.source === source) {
return pub;
}
}
}
/**
* Finds the first track that matches the track's name.
*/
getTrackPublicationByName(name: string): TrackPublication | undefined {
for (const [, pub] of this.trackPublications) {
if (pub.trackName === name) {
return pub;
}
}
}
/**
* Waits until the participant is active and ready to receive data messages
* @returns a promise that resolves when the participant is active
*/
waitUntilActive(): Promise<void> {
if (this.isActive) {
return Promise.resolve();
}
if (this.activeFuture) {
return this.activeFuture.promise;
}
this.activeFuture = new Future<void, Error>();
this.once(ParticipantEvent.Active, () => {
this.activeFuture?.resolve?.();
this.activeFuture = undefined;
});
return this.activeFuture.promise;
}
get connectionQuality(): ConnectionQuality {
return this._connectionQuality;
}
get isCameraEnabled(): boolean {
const track = this.getTrackPublication(Track.Source.Camera);
return !(track?.isMuted ?? true);
}
get isMicrophoneEnabled(): boolean {
const track = this.getTrackPublication(Track.Source.Microphone);
return !(track?.isMuted ?? true);
}
get isScreenShareEnabled(): boolean {
const track = this.getTrackPublication(Track.Source.ScreenShare);
return !!track;
}
get isLocal(): boolean {
return false;
}
/** when participant joined the room */
get joinedAt(): Date | undefined {
if (this.participantInfo) {
return new Date(Number.parseInt(this.participantInfo.joinedAt.toString()) * 1000);
}
return new Date();
}
/** @internal */
updateInfo(info: ParticipantInfo): boolean {
// it's possible the update could be applied out of order due to await
// during reconnect sequences. when that happens, it's possible for server
// to have sent more recent version of participant info while JS is waiting
// to process the existing payload.
// when the participant sid remains the same, and we already have a later version
// of the payload, they can be safely skipped
if (
this.participantInfo &&
this.participantInfo.sid === info.sid &&
this.participantInfo.version > info.version
) {
return false;
}
this.identity = info.identity;
this.sid = info.sid;
this._setName(info.name);
this._setMetadata(info.metadata);
this._setAttributes(info.attributes);
if (
info.state === ParticipantInfo_State.ACTIVE &&
this.participantInfo?.state !== ParticipantInfo_State.ACTIVE
) {
this.emit(ParticipantEvent.Active);
}
if (info.permission) {
this.setPermissions(info.permission);
}
// set this last so setMetadata can detect changes
this.participantInfo = info;
return true;
}
/**
* Updates metadata from server
**/
private _setMetadata(md: string) {
const changed = this.metadata !== md;
const prevMetadata = this.metadata;
this.metadata = md;
if (changed) {
this.emit(ParticipantEvent.ParticipantMetadataChanged, prevMetadata);
}
}
private _setName(name: string) {
const changed = this.name !== name;
this.name = name;
if (changed) {
this.emit(ParticipantEvent.ParticipantNameChanged, name);
}
}
/**
* Updates metadata from server
**/
private _setAttributes(attributes: Record<string, string>) {
const diff = diffAttributes(this.attributes, attributes);
this._attributes = attributes;
if (Object.keys(diff).length > 0) {
this.emit(ParticipantEvent.AttributesChanged, diff);
}
}
/** @internal */
setPermissions(permissions: ParticipantPermission): boolean {
const prevPermissions = this.permissions;
const changed =
permissions.canPublish !== this.permissions?.canPublish ||
permissions.canSubscribe !== this.permissions?.canSubscribe ||
permissions.canPublishData !== this.permissions?.canPublishData ||
permissions.hidden !== this.permissions?.hidden ||
permissions.recorder !== this.permissions?.recorder ||
permissions.canPublishSources.length !== this.permissions.canPublishSources.length ||
permissions.canPublishSources.some(
(value, index) => value !== this.permissions?.canPublishSources[index],
) ||
permissions.canSubscribeMetrics !== this.permissions?.canSubscribeMetrics;
this.permissions = permissions;
if (changed) {
this.emit(ParticipantEvent.ParticipantPermissionsChanged, prevPermissions);
}
return changed;
}
/** @internal */
setIsSpeaking(speaking: boolean) {
if (speaking === this.isSpeaking) {
return;
}
this.isSpeaking = speaking;
if (speaking) {
this.lastSpokeAt = new Date();
}
this.emit(ParticipantEvent.IsSpeakingChanged, speaking);
}
/** @internal */
setConnectionQuality(q: ProtoQuality) {
const prevQuality = this._connectionQuality;
this._connectionQuality = qualityFromProto(q);
if (prevQuality !== this._connectionQuality) {
this.emit(ParticipantEvent.ConnectionQualityChanged, this._connectionQuality);
}
}
/**
* @internal
*/
setDisconnected() {
if (this.activeFuture) {
this.activeFuture.reject?.(new Error('Participant disconnected'));
this.activeFuture = undefined;
}
}
/**
* @internal
*/
setAudioContext(ctx: AudioContext | undefined) {
this.audioContext = ctx;
this.audioTrackPublications.forEach(
(track) => isAudioTrack(track.track) && track.track.setAudioContext(ctx),
);
}
protected addTrackPublication(publication: TrackPublication) {
// forward publication driven events
publication.on(TrackEvent.Muted, () => {
this.emit(ParticipantEvent.TrackMuted, publication);
});
publication.on(TrackEvent.Unmuted, () => {
this.emit(ParticipantEvent.TrackUnmuted, publication);
});
const pub = publication;
if (pub.track) {
pub.track.sid = publication.trackSid;
}
this.trackPublications.set(publication.trackSid, publication);
switch (publication.kind) {
case Track.Kind.Audio:
this.audioTrackPublications.set(publication.trackSid, publication);
break;
case Track.Kind.Video:
this.videoTrackPublications.set(publication.trackSid, publication);
break;
default:
break;
}
}
}
export type ParticipantEventCallbacks = {
trackPublished: (publication: RemoteTrackPublication) => void;
trackSubscribed: (track: RemoteTrack, publication: RemoteTrackPublication) => void;
trackSubscriptionFailed: (trackSid: string, reason?: SubscriptionError) => void;
trackUnpublished: (publication: RemoteTrackPublication) => void;
trackUnsubscribed: (track: RemoteTrack, publication: RemoteTrackPublication) => void;
trackMuted: (publication: TrackPublication) => void;
trackUnmuted: (publication: TrackPublication) => void;
localTrackPublished: (publication: LocalTrackPublication) => void;
localTrackUnpublished: (publication: LocalTrackPublication) => void;
localTrackCpuConstrained: (track: LocalVideoTrack, publication: LocalTrackPublication) => void;
localSenderCreated: (sender: RTCRtpSender, track: Track) => void;
participantMetadataChanged: (prevMetadata: string | undefined, participant?: any) => void;
participantNameChanged: (name: string) => void;
dataReceived: (
payload: Uint8Array,
kind: DataPacket_Kind,
encryptionType?: Encryption_Type,
) => void;
sipDTMFReceived: (dtmf: SipDTMF) => void;
transcriptionReceived: (
transcription: TranscriptionSegment[],
publication?: TrackPublication,
) => void;
isSpeakingChanged: (speaking: boolean) => void;
connectionQualityChanged: (connectionQuality: ConnectionQuality) => void;
trackStreamStateChanged: (
publication: RemoteTrackPublication,
streamState: Track.StreamState,
) => void;
trackSubscriptionPermissionChanged: (
publication: RemoteTrackPublication,
status: TrackPublication.PermissionStatus,
) => void;
mediaDevicesError: (error: Error, kind?: MediaDeviceKind) => void;
audioStreamAcquired: () => void;
participantPermissionsChanged: (prevPermissions?: ParticipantPermission) => void;
trackSubscriptionStatusChanged: (
publication: RemoteTrackPublication,
status: TrackPublication.SubscriptionStatus,
) => void;
attributesChanged: (changedAttributes: Record<string, string>) => void;
localTrackSubscribed: (trackPublication: LocalTrackPublication) => void;
chatMessage: (msg: ChatMessage) => void;
active: () => void;
};