UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

413 lines (370 loc) 13.6 kB
import type { ParticipantInfo, SubscriptionError, UpdateSubscription, UpdateTrackSettings, } from '@livekit/protocol'; import type { SignalClient } from '../../api/SignalClient'; import { DeferrableMap } from '../../utils/deferrable-map'; import type RemoteDataTrack from '../data-track/RemoteDataTrack'; import { ParticipantEvent, TrackEvent } from '../events'; import RemoteAudioTrack from '../track/RemoteAudioTrack'; import type RemoteTrack from '../track/RemoteTrack'; import RemoteTrackPublication from '../track/RemoteTrackPublication'; import RemoteVideoTrack from '../track/RemoteVideoTrack'; import { Track } from '../track/Track'; import type { TrackPublication } from '../track/TrackPublication'; import type { AudioOutputOptions } from '../track/options'; import type { AdaptiveStreamSettings } from '../track/types'; import { getLogContextFromTrack } from '../track/utils'; import type { LoggerOptions } from '../types'; import { isAudioTrack, isRemoteTrack } from '../utils'; import Participant, { ParticipantKind } from './Participant'; import type { ParticipantEventCallbacks } from './Participant'; export default class RemoteParticipant extends Participant { audioTrackPublications: Map<string, RemoteTrackPublication>; videoTrackPublications: Map<string, RemoteTrackPublication>; trackPublications: Map<string, RemoteTrackPublication>; /** A map of data track name to the corresponding {@link RemoteDataTrack}. * @example * // An already existing data track: * const track = remoteParticipant.dataTracks.get("data track name"); * // Wait for a data track which will be published soon: * const track = await remoteParticipant.dataTracks.getDeferred("data track name"); */ dataTracks: DeferrableMap<RemoteDataTrack['info']['name'], RemoteDataTrack>; signalClient: SignalClient; private volumeMap: Map<Track.Source, number>; private audioOutput?: AudioOutputOptions; /** @internal */ static fromParticipantInfo( signalClient: SignalClient, pi: ParticipantInfo, loggerOptions: LoggerOptions, ): RemoteParticipant { return new RemoteParticipant( signalClient, pi.sid, pi.identity, pi.name, pi.metadata, pi.attributes, loggerOptions, pi.kind, ); } protected get logContext() { return { ...super.logContext, remoteParticipantID: this.sid, remoteParticipant: this.identity, }; } /** @internal */ constructor( signalClient: SignalClient, sid: string, identity?: string, name?: string, metadata?: string, attributes?: Record<string, string>, loggerOptions?: LoggerOptions, kind: ParticipantKind = ParticipantKind.STANDARD, ) { super(sid, identity || '', name, metadata, attributes, loggerOptions, kind); this.signalClient = signalClient; this.trackPublications = new Map(); this.audioTrackPublications = new Map(); this.videoTrackPublications = new Map(); this.dataTracks = new DeferrableMap(); this.volumeMap = new Map(); } protected addTrackPublication(publication: RemoteTrackPublication) { super.addTrackPublication(publication); // register action events publication.on(TrackEvent.UpdateSettings, (settings: UpdateTrackSettings) => { this.log.debug('send update settings', { ...this.logContext, ...getLogContextFromTrack(publication), settings, }); this.signalClient.sendUpdateTrackSettings(settings); }); publication.on(TrackEvent.UpdateSubscription, (sub: UpdateSubscription) => { sub.participantTracks.forEach((pt) => { pt.participantSid = this.sid; }); this.signalClient.sendUpdateSubscription(sub); }); publication.on( TrackEvent.SubscriptionPermissionChanged, (status: TrackPublication.PermissionStatus) => { this.emit(ParticipantEvent.TrackSubscriptionPermissionChanged, publication, status); }, ); publication.on( TrackEvent.SubscriptionStatusChanged, (status: TrackPublication.SubscriptionStatus) => { this.emit(ParticipantEvent.TrackSubscriptionStatusChanged, publication, status); }, ); publication.on(TrackEvent.Subscribed, (track: RemoteTrack) => { this.emit(ParticipantEvent.TrackSubscribed, track, publication); }); publication.on(TrackEvent.Unsubscribed, (previousTrack: RemoteTrack) => { this.emit(ParticipantEvent.TrackUnsubscribed, previousTrack, publication); }); publication.on(TrackEvent.SubscriptionFailed, (error: SubscriptionError) => { this.emit(ParticipantEvent.TrackSubscriptionFailed, publication.trackSid, error); }); } getTrackPublication(source: Track.Source): RemoteTrackPublication | undefined { const track = super.getTrackPublication(source); if (track) { return track as RemoteTrackPublication; } } getTrackPublicationByName(name: string): RemoteTrackPublication | undefined { const track = super.getTrackPublicationByName(name); if (track) { return track as RemoteTrackPublication; } } /** * sets the volume on the participant's audio track * by default, this affects the microphone publication * a different source can be passed in as a second argument * if no track exists the volume will be applied when the microphone track is added */ setVolume( volume: number, source: Track.Source.Microphone | Track.Source.ScreenShareAudio = Track.Source.Microphone, ) { this.volumeMap.set(source, volume); const audioPublication = this.getTrackPublication(source); if (audioPublication && audioPublication.track) { (audioPublication.track as RemoteAudioTrack).setVolume(volume); } } /** * gets the volume on the participant's microphone track */ getVolume( source: Track.Source.Microphone | Track.Source.ScreenShareAudio = Track.Source.Microphone, ) { const audioPublication = this.getTrackPublication(source); if (audioPublication && audioPublication.track) { return (audioPublication.track as RemoteAudioTrack).getVolume(); } return this.volumeMap.get(source); } /** @internal */ addSubscribedMediaTrack( mediaTrack: MediaStreamTrack, sid: Track.SID, mediaStream: MediaStream, receiver: RTCRtpReceiver, adaptiveStreamSettings?: AdaptiveStreamSettings, triesLeft?: number, ) { // find the track publication // it's possible for the media track to arrive before participant info let publication = this.getTrackPublicationBySid(sid); // it's also possible that the browser didn't honor our original track id // FireFox would use its own local uuid instead of server track id if (!publication) { if (!sid.startsWith('TR')) { // find the first track that matches type this.trackPublications.forEach((p) => { if (!publication && mediaTrack.kind === p.kind.toString()) { publication = p; } }); } } // when we couldn't locate the track, it's possible that the metadata hasn't // yet arrived. Wait a bit longer for it to arrive, or fire an error if (!publication) { if (triesLeft === 0) { this.log.error('could not find published track', { ...this.logContext, trackSid: sid, }); this.emit(ParticipantEvent.TrackSubscriptionFailed, sid); return; } if (triesLeft === undefined) triesLeft = 20; setTimeout(() => { this.addSubscribedMediaTrack( mediaTrack, sid, mediaStream, receiver, adaptiveStreamSettings, triesLeft! - 1, ); }, 150); return; } if (mediaTrack.readyState === 'ended') { this.log.error( 'unable to subscribe because MediaStreamTrack is ended. Do not call MediaStreamTrack.stop()', { ...this.logContext, ...getLogContextFromTrack(publication) }, ); this.emit(ParticipantEvent.TrackSubscriptionFailed, sid); return; } const isVideo = mediaTrack.kind === 'video'; let track: RemoteTrack; if (isVideo) { track = new RemoteVideoTrack(mediaTrack, sid, receiver, adaptiveStreamSettings); } else { track = new RemoteAudioTrack(mediaTrack, sid, receiver, this.audioContext, this.audioOutput); } // set track info track.source = publication.source; // keep publication's muted status track.isMuted = publication.isMuted; track.setMediaStream(mediaStream); track.start(); publication.setTrack(track); // set participant volumes on new audio tracks if (this.volumeMap.has(publication.source) && isRemoteTrack(track) && isAudioTrack(track)) { track.setVolume(this.volumeMap.get(publication.source)!); } return publication; } /** @internal */ get hasMetadata(): boolean { return !!this.participantInfo; } /** * @internal */ getTrackPublicationBySid(sid: Track.SID): RemoteTrackPublication | undefined { return this.trackPublications.get(sid); } /** @internal */ updateInfo(info: ParticipantInfo): boolean { if (!super.updateInfo(info)) { return false; } // we are getting a list of all available tracks, reconcile in here // and send out events for changes // reconcile track publications, publish events only if metadata is already there // i.e. changes since the local participant has joined const validTracks = new Map<string, RemoteTrackPublication>(); const newTracks = new Map<string, RemoteTrackPublication>(); info.tracks.forEach((ti) => { let publication = this.getTrackPublicationBySid(ti.sid); if (!publication) { // new publication const kind = Track.kindFromProto(ti.type); if (!kind) { return; } publication = new RemoteTrackPublication( kind, ti, this.signalClient.connectOptions?.autoSubscribe, { loggerContextCb: () => this.logContext, loggerName: this.loggerOptions?.loggerName }, ); publication.updateInfo(ti); newTracks.set(ti.sid, publication); const existingTrackOfSource = Array.from(this.trackPublications.values()).find( (publishedTrack) => publishedTrack.source === publication?.source, ); if (existingTrackOfSource && publication.source !== Track.Source.Unknown) { this.log.debug( `received a second track publication for ${this.identity} with the same source: ${publication.source}`, { ...this.logContext, oldTrack: getLogContextFromTrack(existingTrackOfSource), newTrack: getLogContextFromTrack(publication), }, ); } this.addTrackPublication(publication); } else { publication.updateInfo(ti); } validTracks.set(ti.sid, publication); }); // detect removed tracks this.trackPublications.forEach((publication) => { if (!validTracks.has(publication.trackSid)) { this.log.trace('detected removed track on remote participant, unpublishing', { ...this.logContext, ...getLogContextFromTrack(publication), }); this.unpublishTrack(publication.trackSid, true); } }); // always emit events for new publications, Room will not forward them unless it's ready newTracks.forEach((publication) => { this.emit(ParticipantEvent.TrackPublished, publication); }); return true; } /** @internal */ unpublishTrack(sid: Track.SID, sendUnpublish?: boolean) { const publication = <RemoteTrackPublication>this.trackPublications.get(sid); if (!publication) { return; } // also send unsubscribe, if track is actively subscribed const { track } = publication; if (track) { track.stop(); publication.setTrack(undefined); } // remove track from maps only after unsubscribed has been fired this.trackPublications.delete(sid); // remove from the right type map switch (publication.kind) { case Track.Kind.Audio: this.audioTrackPublications.delete(sid); break; case Track.Kind.Video: this.videoTrackPublications.delete(sid); break; default: break; } if (sendUnpublish) { this.emit(ParticipantEvent.TrackUnpublished, publication); } } /** * @internal */ async setAudioOutput(output: AudioOutputOptions) { this.audioOutput = output; const promises: Promise<void>[] = []; this.audioTrackPublications.forEach((pub) => { if (isAudioTrack(pub.track) && isRemoteTrack(pub.track)) { promises.push(pub.track.setSinkId(output.deviceId ?? 'default')); } }); await Promise.all(promises); } /** @internal */ addRemoteDataTrack(remoteDataTrack: RemoteDataTrack) { this.dataTracks.set(remoteDataTrack.info.name, remoteDataTrack); } /** @internal */ removeRemoteDataTrack(remoteDataTrackSid: RemoteDataTrack['info']['sid']) { for (const [name, dataTrack] of this.dataTracks.entries()) { if (remoteDataTrackSid === dataTrack.info.sid) { this.dataTracks.delete(name); } } } /** @internal */ emit<E extends keyof ParticipantEventCallbacks>( event: E, ...args: Parameters<ParticipantEventCallbacks[E]> ): boolean { this.log.trace('participant event', { ...this.logContext, event, args }); return super.emit(event, ...args); } }