UNPKG

twilio-video

Version:

Twilio Video JavaScript Library

708 lines (641 loc) 26.7 kB
'use strict'; const EventEmitter = require('./eventemitter'); const RemoteParticipant = require('./remoteparticipant'); const StatsReport = require('./stats/statsreport'); const MonitorRegistry = require('./insights/monitorregistry'); const telemetry = require('./insights/telemetry'); const { flatMap, valueToJSON } = require('./util'); let nInstances = 0; /** * A {@link Room} represents communication between you and one or more * {@link RemoteParticipant}s sharing {@link AudioTrack}s and * {@link VideoTrack}s. * <br><br> * You can connect to a {@link Room} by calling {@link module:twilio-video.connect}. * @extends EventEmitter * @property {?RemoteParticipant} dominantSpeaker - The Dominant Speaker in the * {@link Room}, if any * @property {boolean} isRecording - Whether or not the {@link Room} is being * recorded * @property {LocalParticipant} localParticipant - Your {@link LocalParticipant} * in the {@link Room} * @property {string} mediaRegion - String indicating geographical region * where media is processed for the {@link Room}. * @property {string} name - The {@link Room}'s name * @property {Map<Participant.SID, RemoteParticipant>} participants - * The {@link RemoteParticipant}s participating in this {@link Room} * @property {Room.SID} sid - The {@link Room}'s SID * @property {string} state - "connected", "reconnecting", or "disconnected" * @throws {SignalingConnectionDisconnectedError} * @emits Room#disconnected * @emits Room#transcription * @emits Room#participantConnected * @emits Room#participantDisconnected * @emits Room#participantReconnected * @emits Room#participantReconnecting * @emits Room#reconnected * @emits Room#reconnecting * @emits Room#recordingStarted * @emits Room#recordingStopped * @emits Room#trackDimensionsChanged * @emits Room#trackDisabled * @emits Room#trackEnabled * @emits Room#trackMessage * @emits Room#trackPublished * @emits Room#trackPublishPriorityChanged * @emits Room#trackStarted * @emits Room#trackSubscribed * @emits Room#trackSwitchedOff * @emits Room#trackSwitchedOn * @emits Room#trackUnpublished * @emits Room#trackUnsubscribed * @emits Room#trackWarning * @emits Room#trackWarningsCleared */ class Room extends EventEmitter { /** * Construct a {@link Room}. * @param {RoomSignaling} signaling * @param {?object} [options={}] */ constructor(localParticipant, signaling, options) { super(); const log = options.log.createLog('default', this); const participants = new Map(); /* istanbul ignore next */ Object.defineProperties(this, { _log: { value: log }, _clientTrackSwitchOffControl: { value: options.clientTrackSwitchOffControl || 'disabled' }, _contentPreferencesMode: { value: options.contentPreferencesMode || 'disabled' }, _instanceId: { value: ++nInstances }, _options: { value: options }, _participants: { value: participants }, _signaling: { value: signaling }, _insightsMonitors: { value: options.eventObserver && options.insights ? new MonitorRegistry(log) : null }, dominantSpeaker: { enumerable: true, get() { return this.participants.get(signaling.dominantSpeakerSid) || null; } }, isRecording: { enumerable: true, get() { return signaling.recording.isEnabled || false; } }, localParticipant: { enumerable: true, value: localParticipant }, name: { enumerable: true, value: signaling.name }, participants: { enumerable: true, value: participants }, sid: { enumerable: true, value: signaling.sid }, state: { enumerable: true, get() { return signaling.state; } }, mediaRegion: { enumerable: true, value: signaling.mediaRegion } }); handleLocalParticipantEvents(this, localParticipant); handleRecordingEvents(this, signaling.recording); handleSignalingEvents(this, signaling); verifyNoiseCancellation(this); log.info('Created a new Room:', this.name); log.debug('Initial RemoteParticipants:', Array.from(this._participants.values())); } toString() { if (!this._instanceId) { return '[Room (uninitialized)]'; } return `[Room #${this._instanceId}: ${this.sid}]`; } /** * Disconnect from the {@link Room}. * @returns {this} */ disconnect() { this._log.info('Disconnecting'); this._signaling.disconnect(); return this; } /** * Get the {@link Room}'s media statistics. This is not supported in Safari 12.0 or below * due to this bug : https://bugs.webkit.org/show_bug.cgi?id=192601 * * @returns {Promise.<Array<StatsReport>>} */ getStats() { return this._signaling.getStats().then(responses => Array.from(responses).map(([id, response]) => new StatsReport(id, Object.assign({}, response, { localAudioTrackStats: rewriteLocalTrackIds(this, response.localAudioTrackStats), localVideoTrackStats: rewriteLocalTrackIds(this, response.localVideoTrackStats) })) ) ); } /** * Restart the muted local media {@link Track}s and play inadvertently paused HTMLMediaElements * that are attached to local and remote media {@link Track}s. This method is useful mainly on * mobile browsers (Safari and Chrome on iOS), where there is a possibility that the muted local * media {@link Track}s are never unmuted and inadvertently paused HTMLMediaElements are never * played again, especially after handling an incoming phone call. * @returns {this} */ refreshInactiveMedia() { const { tracks: localTrackPublications } = this.localParticipant; const localMediaTracks = Array.from(localTrackPublications.values()) .filter(({ track: { kind } }) => kind !== 'data') .map(({ track }) => track); const remoteMediaTracks = flatMap(this.participants, participants => Array.from(participants.tracks.values())) .filter(({ track }) => track && track.kind !== 'data') .map(({ track }) => track); const mediaTracks = localMediaTracks.concat(remoteMediaTracks); const unmuteEvent = new Event('unmute'); localMediaTracks.forEach(({ isMuted, mediaStreamTrack }) => { if (isMuted) { mediaStreamTrack.dispatchEvent(unmuteEvent); } }); const pauseEvent = new Event('pause'); mediaTracks.forEach(({ _attachments: attachments, _elShims: elShims }) => attachments.forEach(el => { const shim = elShims.get(el); const isInadvertentlyPaused = el.paused && shim && !shim.pausedIntentionally(); if (isInadvertentlyPaused) { el.dispatchEvent(pauseEvent); } })); return this; } toJSON() { return valueToJSON(this); } } function verifyNoiseCancellation(room) { const allowedAudioProcessors = room.localParticipant._signaling.audioProcessors; room.localParticipant.audioTracks.forEach(({ track }) => { const noiseCancellation = track.noiseCancellation; if (noiseCancellation && !allowedAudioProcessors.includes(noiseCancellation.vendor)) { room._log.warn(`${noiseCancellation.vendor} is not supported in this room. disabling it permanently`); noiseCancellation.disablePermanently(); } }); } function rewriteLocalTrackIds(room, trackStats) { const localParticipantSignaling = room.localParticipant._signaling; return trackStats.reduce((trackStats, trackStat) => { const publication = localParticipantSignaling.tracks.get(trackStat.trackId); const trackSender = localParticipantSignaling.getSender(publication); return trackSender ? [Object.assign({}, trackStat, { trackId: trackSender.id })].concat(trackStats) : trackStats; }, []); } /** * A {@link Room.SID} is a 34-character string starting with "RM" * that uniquely identifies a {@link Room}. * @type string * @typedef Room.SID */ /** * The Dominant Speaker in the {@link Room} changed. Either the Dominant Speaker * is a new {@link RemoteParticipant} or the Dominant Speaker has been reset and * is now null. * @param {?RemoteParticipant} dominantSpeaker - The Dominant Speaker in the * {@link Room}, if any * @event Room#dominantSpeakerChanged */ /** * Your {@link LocalParticipant} was disconnected from the {@link Room} and all * other {@link RemoteParticipant}s. * @param {Room} room - The {@link Room} your * {@link LocalParticipant} was disconnected from * @param {?TwilioError} error - Present when the {@link LocalParticipant} got * disconnected from the {@link Room} unexpectedly * @event Room#disconnected * @example * myRoom.on('disconnected', function(room, error) { * if (error) { * console.log('Unexpectedly disconnected:', error); * } * myRoom.localParticipant.tracks.forEach(function(track) { * track.stop(); * track.detach(); * }); * }); */ /** * Emitted when transcription data is received. * This event is only emitted when the {@link Room} is configured for transcription. * @param {TranscriptionEvent} transcriptionEvent - The transcription event data. * @param {string} transcriptionEvent.language_code - The BCP-47 language code (e.g., 'en-US') of the transcribed text. * @param {boolean} transcriptionEvent.partial_results - Whether the transcription is a final or a partial result. * @param {string} transcriptionEvent.participant - The SID of the speaking participant. * @param {number} transcriptionEvent.sequence_number - Starts with one and increments monotonically. * @param {string} transcriptionEvent.timestamp - ISO 8601 timestamp of when the transcription was generated. * @param {string} transcriptionEvent.track - The SID of the audio track being transcribed. * @param {string} transcriptionEvent.transcription - The transcribed text. * @param {string} transcriptionEvent.type - Constant 'extension_transcriptions'. * @event Room#transcription * @example * room.on('transcription', transcriptionEvent => { * console.log(`${transcriptionEvent.participant}: ${transcriptionEvent.transcription}`); * }); */ /** * A {@link RemoteParticipant} joined the {@link Room}. In Large Group Rooms (Maximum * Participants greater than 50), this event is raised only when a {@link RemoteParticipant} * publishes at least one {@link LocalTrack}. * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who joined * @event Room#participantConnected * @example * myRoom.on('participantConnected', function(participant) { * console.log(participant.identity + ' joined the Room'); * }); */ /** * A {@link RemoteParticipant} left the {@link Room}. In Large Group Rooms (Maximum * Participants greater than 50), this event is raised only when a {@link RemoteParticipant} * unpublishes all its {@link LocalTrack}s. * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who left * @event Room#participantDisconnected * @example * myRoom.on('participantDisconnected', function(participant) { * console.log(participant.identity + ' left the Room'); * participant.tracks.forEach(function(track) { * track.detach().forEach(function(mediaElement) { * mediaElement.remove(); * }); * }); * }); */ /** * A {@link RemoteParticipant} has reconnected to the {@link Room} after a signaling connection disruption. * @param {RemoteParticipant} participant - The {@link RemoteParticipant} that has reconnected. * @event Room#participantReconnected * @example * myRoom.on('participantReconnected', participant => { * console.log(participant.identity + ' reconnected to the Room'); * }); */ /** * A {@link RemoteParticipant} is reconnecting to the {@link Room} after a signaling connection disruption. * @param {RemoteParticipant} participant - The {@link RemoteParticipant} that is reconnecting. * @event Room#participantReconnecting * @example * myRoom.on('participantReconnecting', participant => { * console.log(participant.identity + ' is reconnecting to the Room'); * }); */ /** * Your application successfully reconnected to the {@link Room}. When this * event is emitted, the {@link Room} is in state "connected". * @event Room#reconnected * @example * myRoom.on('reconnected', () => { * console.log('Reconnected!'); * }); */ /** * Your application is reconnecting to the {@link Room}. This happens when there * is a disruption in your signaling connection and/or your media connection. When * this event is emitted, the {@link Room} is in state "reconnecting". If reconnecting * succeeds, the {@link Room} will emit a "reconnected" event. * @param {MediaConnectionError|SignalingConnectionDisconnectedError} error - A * {@link MediaConnectionError} if your application is reconnecting due to a * disruption in your media connection, or a {@link SignalingConnectionDisconnectedError} * if your application is reconnecting due to a disruption in your signaling connection * @event Room#reconnecting * @example * myRoom.on('reconnecting', error => { * if (error.code === 53001) { * console.log('Reconnecting your signaling connection!', error.message); * } else if (error.code === 53405) { * console.log('Reconnecting your media connection!', error.message); * } * }); */ /** * The {@link Room} is now being recorded * @event Room#recordingStarted */ /** * The {@link Room} is no longer being recorded * @event Room#recordingStopped */ /** * One of the {@link RemoteParticipant}'s {@link VideoTrack}'s dimensions changed. * @param {RemoteVideoTrack} track - The {@link RemoteVideoTrack} whose dimensions changed * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteVideoTrack}'s dimensions changed * @event Room#trackDimensionsChanged */ /** * A {@link RemoteTrack} was disabled by a {@link RemoteParticipant} in the {@link Room}. * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} that represents disabled {@link RemoteTrack} * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who * disabled the {@link RemoteTrack} * @event Room#trackDisabled */ /** * A {@link RemoteTrack} was enabled by a {@link RemoteParticipant} in the {@link Room}. * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} that represents enabled {@link RemoteTrack} * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who * enabled the {@link RemoteTrack} * @event Room#trackEnabled */ /** * A message was received over one of the {@link RemoteParticipant}'s * {@link RemoteDataTrack}'s. * @param {string|ArrayBuffer} data * @param {RemoteDataTrack} track - The {@link RemoteDataTrack} over which the * message was received * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteDataTrack} received the message * @event Room#trackMessage */ /** * A {@link RemoteTrack} was published by a {@link RemoteParticipant} after * connecting to the {@link Room}. This event is not emitted for * {@link RemoteTrack}s that were published while the {@link RemoteParticipant} * was connecting to the {@link Room}. * @event Room#trackPublished * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * which represents the published {@link RemoteTrack} * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who * published the {@link RemoteTrack} * @example * function trackPublished(publication, participant) { * console.log(`RemoteParticipant ${participant.sid} published Track ${publication.trackSid}`); * } * * // Handle RemoteTracks published after connecting to the Room. * room.on('trackPublished', trackPublished); * * room.on('participantConnected', participant => { * // Handle RemoteTracks published while connecting to the Room. * participant.trackPublications.forEach(publication => trackPublished(publication, participant)); * }); */ /** * One of a {@link RemoteParticipant}'s {@link RemoteTrack}s in the {@link Room} started. * @param {RemoteTrack} track - The {@link RemoteTrack} that started * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} started * @event Room#trackStarted */ /** * A {@link RemoteParticipant}'s {@link RemoteTrack} was subscribed to. * @param {RemoteTrack} track - The {@link RemoteTrack} that was subscribed * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * for the {@link RemoteTrack} that was subscribed to * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} was subscribed * @event Room#trackSubscribed * @example * room.on('trackSubscribed', function(track, publication, participant) { * var participantView = document.getElementById('participant-view-' + participant.identity); * participantView.appendChild(track.attach()); * }); */ /** * A {@link RemoteParticipant}'s {@link RemoteTrack} was switched off. * @param {RemoteTrack} track - The {@link RemoteTrack} that was switched off * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * for the {@link RemoteTrack} that was subscribed to * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} was switched off * @event Room#trackSwitchedOff */ /** * A {@link RemoteParticipant}'s {@link RemoteTrack} was switched on. * @param {RemoteTrack} track - The {@link RemoteTrack} that was switched on * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * for the {@link RemoteTrack} that was subscribed to * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} was switched on * @event Room#trackSwitchedOn */ /** * A {@link RemoteParticipant}'s {@link RemoteTrack} could not be subscribed to. * @param {TwilioError} error - The reason the {@link RemoteTrack} could not be * subscribed to * @param {RemoteTrackPublication} publication - The * {@link RemoteTrackPublication} for the {@link RemoteTrack} that could not * be subscribed to * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} could not be subscribed to * @event Room#trackSubscriptionFailed */ /** * The {@link RemoteTrack}'s publish {@link Track.Priority} was changed by the * {@link RemoteParticipant}. * @param {Track.Priority} priority - the {@link RemoteTrack}'s new publish * {@link Track.Priority}; * @param {RemoteTrackPublication} publication - The * {@link RemoteTrackPublication} for the {@link RemoteTrack} that changed priority * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} changed priority * @event Room#trackPublishPriorityChanged */ /** * A {@link RemoteTrack} was unpublished by a {@link RemoteParticipant} to the {@link Room}. * @event Room#trackUnpublished * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * which represents the unpublished {@link RemoteTrack} * @param {RemoteParticipant} participant - The {@link RemoteParticipant} who * unpublished the {@link RemoteTrack} */ /** * A {@link RemoteParticipant}'s {@link RemoteTrack} was unsubscribed from. * @param {RemoteTrack} track - The {@link RemoteTrack} that was unsubscribed * @param {RemoteTrackPublication} publication - The {@link RemoteTrackPublication} * for the {@link RemoteTrack} that was unsubscribed from * @param {RemoteParticipant} participant - The {@link RemoteParticipant} whose * {@link RemoteTrack} was unsubscribed * @event Room#trackUnsubscribed * @example * room.on('trackUnsubscribed', function(track, publication, participant) { * track.detach().forEach(function(mediaElement) { * mediaElement.remove(); * }); * }); */ /** * One of the {@link LocalParticipant}'s {@link LocalTrackPublication}s in the {@link Room} encountered a warning. * This event is only raised if you enabled warnings using <code>notifyWarnings</code> in <code>ConnectOptions</code>. * @param {string} name - The warning that was raised. * @param {LocalTrackPublication} publication - The {@link LocalTrackPublication} that encountered the warning. * @param {LocalParticipant} participant - The {@link LocalParticipant} * @event Room#trackWarning * @example * room.on('trackWarning', (name, publication, participant) => { * if (name === 'recording-media-lost') { * log(`LocalTrack ${publication.track.name} is not recording media.`, * name, publication, participant); * * // Wait a reasonable amount of time to clear the warning. * const timer = setTimeout(() => { * // If the warning is not cleared, you can manually * // reconnect to the room, or show a dialog to the user * }, 5000); * * room.once('trackWarningsCleared', (publication, participant) => { * log('LocalTrack warnings have cleared!', * publication, participant); * clearTimeout(timer); * }); * } }); */ /** * One of the {@link LocalParticipant}'s {@link LocalTrackPublication}s in the {@link Room} cleared all warnings. * This event is only raised if you enabled warnings using <code>notifyWarnings</code> in <code>ConnectOptions</code>. * @param {LocalTrackPublication} publication - The {@link LocalTrackPublication} that cleared all warnings. * @param {LocalParticipant} participant - The {@link LocalParticipant} * @event Room#trackWarningsCleared */ function connectParticipant(room, participantSignaling) { const { _log: log, _clientTrackSwitchOffControl: clientTrackSwitchOffControl, _contentPreferencesMode: contentPreferencesMode, _options: { MediaStream, mapMediaElement, disposeMediaElement } } = room; const participant = new RemoteParticipant(participantSignaling, { log, clientTrackSwitchOffControl, contentPreferencesMode, MediaStream, mapMediaElement, disposeMediaElement }); log.info('A new RemoteParticipant connected:', participant); room._participants.set(participant.sid, participant); room.emit('participantConnected', participant); // Reemit Track and RemoteParticipant events. const eventListeners = [ ['reconnected', 'participantReconnected'], ['reconnecting', 'participantReconnecting'], 'trackDimensionsChanged', 'trackDisabled', 'trackEnabled', 'trackMessage', 'trackPublished', 'trackPublishPriorityChanged', 'trackStarted', 'trackSubscribed', 'trackSubscriptionFailed', 'trackSwitchedOff', 'trackSwitchedOn', 'trackUnpublished', 'trackUnsubscribed' ].map(eventOrPair => { const [event, participantEvent] = Array.isArray(eventOrPair) ? eventOrPair : [eventOrPair, eventOrPair]; function reemit() { const args = [].slice.call(arguments); args.unshift(participantEvent); args.push(participant); room.emit(...args); } participant.on(event, reemit); return [event, reemit]; }); participant.once('disconnected', function participantDisconnected() { const dominantSpeaker = room.dominantSpeaker; log.info('RemoteParticipant disconnected:', participant); room._participants.delete(participant.sid); eventListeners.forEach(args => { participant.removeListener(args[0], args[1]); }); room.emit('participantDisconnected', participant); if (participant === dominantSpeaker) { room.emit('dominantSpeakerChanged', room.dominantSpeaker); } }); } function handleLocalParticipantEvents(room, localParticipant) { const events = ['trackWarning', 'trackWarningsCleared'].map(event => ({ eventName: event, handler: (...args) => room.emit(event, ...[...args, localParticipant]), })); events.forEach(({ eventName, handler }) => localParticipant.on(eventName, handler)); room.once('disconnected', () => events.forEach(({ eventName, handler }) => localParticipant.removeListener(eventName, handler))); } function handleRecordingEvents(room, recording) { recording.on('updated', function updated() { const started = recording.isEnabled; room._log.info(`Recording ${started ? 'started' : 'stopped'}`); room.emit(`recording${started ? 'Started' : 'Stopped'}`); }); } function handleSignalingEvents(room, signaling) { const log = room._log; // Reemit RemoteParticipant events from the RoomSignaling. log.debug('Creating a new RemoteParticipant for each ParticipantSignaling ' + 'in the RoomSignaling'); signaling.participants.forEach(connectParticipant.bind(null, room)); log.debug('Setting up RemoteParticipant creation for all subsequent ' + 'ParticipantSignalings that connect to the RoomSignaling'); signaling.on('participantConnected', connectParticipant.bind(null, room)); signaling.on('dominantSpeakerChanged', () => room.emit('dominantSpeakerChanged', room.dominantSpeaker)); signaling.on('transcription', data => room.emit('transcription', data)); // Reemit state transition events from the RoomSignaling. signaling.on('stateChanged', function stateChanged(state, error) { log.info('Transitioned to state:', state); switch (state) { case 'disconnected': telemetry.room.disconnected(); room.participants.forEach(participant => { participant._unsubscribeTracks(); }); room.emit(state, room, error); room.localParticipant.tracks.forEach(publication => { publication.unpublish(); }); if (room._insightsMonitors) { room._insightsMonitors.cleanup(); } signaling.removeListener('stateChanged', stateChanged); break; case 'reconnecting': telemetry.room.reconnecting(error); // NOTE(mpatwardhan): `stateChanged` can get emitted with StateMachine locked. // Do not signal public events synchronously with lock held. setTimeout(() => room.emit('reconnecting', error), 0); break; default: telemetry.room.reconnected(); // NOTE(mpatwardhan): `stateChanged` can get emitted with StateMachine locked. // Do not signal public events synchronously with lock held. setTimeout(() => room.emit('reconnected'), 0); } }); } module.exports = Room;