src/media/LocalMediaHandler.ts
import EventEmitter from 'eventemitter3';
import {clone, cloneDeep} from 'lodash-es';
import {MediaTypes} from '../types';
import {ManagedStream} from './ManagedStream';
type UpdateMedia = (
audio: MediaStreamConstraints['audio'],
video: MediaStreamConstraints['video'],
screen: boolean,
) => Promise<void>;
interface Events {
videoStream: [];
'*': [];
}
/**
* Provides access to methods for managing actions related to local media. Instances of this class
* are obtained calling {@link Call#getLocalMediaHandler} or {@link Conference#getLocalMediaHandler}
*
* ## Events
*
* - `videoStream` - Emitted every time videoStream changes.
* - `*` - Emitted every time a change that can affect to any property happens.
*/
export class LocalMediaHandler {
public readonly emitter: EventEmitter<Events> = new EventEmitter();
private _mediaTypes: MediaTypes = {
audio: true,
video: true,
screen: false,
};
private _mediaConstraints: MediaStreamConstraints;
private _updateMedia: UpdateMedia;
private _videoStream?: ManagedStream;
private _audioOnlyStream?: ManagedStream;
private _availableAudioInputDevices: MediaDeviceInfo[] = [];
private _availableAudioOutputDevices: MediaDeviceInfo[] = [];
private _availableVideoInputDevices: MediaDeviceInfo[] = [];
private _activeAudioInputDevice?: MediaDeviceInfo;
private _activeVideoInputDevice?: MediaDeviceInfo;
private _audioMuted = false;
private _videoMuted = false;
private _beforeShareScreenVideo = true;
/** @ignore */
public constructor(mediaConstraints: MediaStreamConstraints = {audio: true, video: true}, updateMedia: UpdateMedia) {
this._mediaConstraints = cloneDeep(mediaConstraints);
this._updateMedia = updateMedia;
}
/**
* Return the media types configured for the call
* @return {MediaTypes}
*/
public getMediaTypes(): MediaTypes {
return clone(this._mediaTypes);
}
/**
* Update the enabled local media. This feature is only available
* when the call has already been connected if "media-update" capability is present.
* @param {MediaTypes} mediaTypes The requested media
* @return {Promise<undefined>}
*
* @example <caption>Disable video</caption>
* await call.setMediaTypes({audio: true, video: false, screen: false});
* console.log("media updated");
*/
public async setMediaTypes(mediaTypes: MediaTypes): Promise<void> {
if (mediaTypes.video && mediaTypes.screen) {
throw new Error('Simultaneous screen sharing and video is not supported');
}
const {audio, video} = this.getCurrentMediaConstraints({mediaTypes});
await this._updateMedia(audio, video, mediaTypes.screen);
this._mediaTypes = clone(mediaTypes);
this.emitter.emit('*');
if (this._mediaTypes.screen && this._videoStream) {
this._videoStream.getVideoTracks().forEach((screenTrack) => {
screenTrack.onended = async () => this.toggleScreen(false);
});
}
}
/**
* Return the media constraints configured for the call
* @return {MediaStreamConstraints}
*/
public getMediaConstraints(): MediaStreamConstraints {
return cloneDeep(this._mediaConstraints);
}
/**
* Update constraints of local media. This feature is only available when the call
* has already been connected if "media-update" capability is present.
* @param {MediaStreamConstraints} mediaConstraints The requested media constraints
* @return {Promise<undefined>}
*
* @example <caption>Change video resolution</caption>
* const audio = true;
* const video = {
* height: 256,
* width: 256,
* };
* await call.setMediaConstraints({audio, video});
* console.log("media updated");
*/
public async setMediaConstraints(mediaConstraints: MediaStreamConstraints): Promise<void> {
const {audio, video} = this.getCurrentMediaConstraints({mediaConstraints});
const {screen} = this.getMediaTypes();
await this._updateMedia(audio, video, screen);
this._mediaConstraints = cloneDeep(mediaConstraints);
this.emitter.emit('*');
}
/**
* Obtains current enabled media stream constraints.
* This is a combination of the media stream constraints configured
* for the call and the current media types.
* @param {{mediaTypes: ?MediaTypes, mediaConstraints: ?MediaStreamConstraints}} param
* @return {MediaStreamConstraints}
*/
public getCurrentMediaConstraints({
mediaTypes = this._mediaTypes,
mediaConstraints = this._mediaConstraints,
} = {}): MediaStreamConstraints {
return {
audio: mediaTypes.audio && cloneDeep(mediaConstraints.audio),
video: mediaTypes.video && cloneDeep(mediaConstraints.video),
};
}
/**
* Returns the local video stream
* @return {ManagedStream|undefined}
*/
public getVideoStream(): ManagedStream|undefined {
return this._videoStream || this._audioOnlyStream;
}
/** @ignore */
public setVideoStream(stream?: MediaStream): void {
if (this._videoStream && this._videoStream.getMediaStream() === stream) {
return;
}
if (this._videoStream) {
this._videoStream.stop();
delete this._videoStream;
}
if (stream) {
this._videoStream = new ManagedStream(stream, true);
// Restore muted audio/video
this.muteAudio(this._audioMuted);
this.muteVideo(this._videoMuted);
}
this.emitter.emit('videoStream');
this.emitter.emit('*');
}
/** @ignore */
public setAudioOnlyStream(stream?: MediaStream): void {
if (this._audioOnlyStream && this._audioOnlyStream.getMediaStream() === stream) {
return;
}
if (this._audioOnlyStream) {
this._audioOnlyStream.stop();
delete this._audioOnlyStream;
}
if (stream) {
this._audioOnlyStream = new ManagedStream(stream, true);
// Restore muted audio/video
this.muteAudio(this._audioMuted);
this.muteVideo(this._videoMuted);
}
if (!this._videoStream) {
this.emitter.emit('videoStream');
}
this.emitter.emit('*');
}
/**
* Returns a boolean value indicating if the call has a local audio track
* @return {boolean}
*/
public hasAudioTrack(): boolean {
if (this._audioOnlyStream) {
return this._audioOnlyStream.hasAudioTracks();
}
if (this._videoStream) {
return this._videoStream.hasAudioTracks();
}
return false;
}
/**
* Returns a boolean value indicating if the call has a local video track
* @return {boolean}
*/
public hasVideoTrack(): boolean {
return !!this._videoStream && this._videoStream.hasVideoTracks();
}
/**
* Returns a boolean value indicating if local audio is muted
* @return {boolean}
*/
public isAudioMuted(): boolean {
if (this._audioOnlyStream) {
return this._audioOnlyStream.isAudioMuted();
}
if (this._videoStream) {
return this._videoStream.isAudioMuted();
}
return true;
}
/**
* Mutes or unmutes the audio. If no parameter is specified it toggles current value.
* @param {boolean} [mute] If specified forces audio to be mute or unmute
*/
public muteAudio(mute = !this.isAudioMuted()): void {
if (this._audioOnlyStream) {
this._audioOnlyStream.muteAudio(mute);
}
if (this._videoStream) {
this._videoStream.muteAudio(mute);
}
this._audioMuted = mute;
}
/**
* Returns a boolean value indication if local video is muted
* @return {boolean}
*/
public isVideoMuted(): boolean {
return !!this._videoStream && this._videoStream.isVideoMuted();
}
/**
* Mutes or unmutes the video. If no parameter is specified it toggles current value.
*
* Note that this method just turns the emitted video black and implies no media renegotiation.
* Therefore, a video track will continue to be streamed. If you are instead looking for a method
* to stop sending video altogether check {@link LocalMediaHandler.toggleVideo}.
* @param {boolean} [mute] When specified forces video to be mute or unmute
*/
public muteVideo(mute = !this.isVideoMuted()): void {
if (this._videoStream) {
this._videoStream.muteVideo(mute);
}
this._videoMuted = mute;
}
/**
* Indicates if video can be enabled or disabled
* @return {boolean}
*/
public canToggleVideo(): boolean {
return this._availableVideoInputDevices.length > 0 && !this._mediaTypes.screen;
}
/**
* Enables or disables the video. If no parameter is specified it toggles current value.
*
* Note that this method will imply a media renegotation because the video track will be
* added or removed from the RTCPeerConnection. As a consequence, remote participants
* will receive a change in their remote participants streams.
* @param {boolean} [video] If specified forces video to be enabled or not
* @return {Promise<undefined>}
*/
public async toggleVideo(video = !this._mediaTypes.video): Promise<void> {
const {audio, screen} = this._mediaTypes;
return this.setMediaTypes({audio, video, screen});
}
/**
* Shares the screen. This feature is only available if "screen-sharing" capability is present.
* @param {boolean} [screen] send screen if true, camera if false.
* If not specified it toggles from the last state.
* @return {Promise<undefined>}
*/
public async toggleScreen(screen = !this._mediaTypes.screen): Promise<void> {
const {audio} = this._mediaTypes;
let {video} = this._mediaTypes;
if (screen) {
this._beforeShareScreenVideo = video;
video = false;
} else {
video = this._beforeShareScreenVideo;
}
await this.setMediaTypes({audio, video, screen});
}
/**
* Updates media types adding video when screen is false or adds an internal mark to know video must be
* enabled when screen sharing ends.
* @return {Promise<undefined>}
*/
public async addVideo(): Promise<void> {
if (this._mediaTypes.screen) {
this._beforeShareScreenVideo = true;
return;
}
if (this._mediaTypes.video) {
return;
}
await this.setMediaTypes({
audio: this._mediaTypes.audio,
video: true,
screen: this._mediaTypes.screen,
});
}
/**
* Returns the list of available audio input devices
* @return {MediaDeviceInfo[]} A list of MediaDeviceInfo objects that can be used as audio input
*/
public getAvailableAudioInputDevices(): MediaDeviceInfo[] {
return this._availableAudioInputDevices;
}
/**
* Replaces the list of available audio input devices
* @param {MediaDeviceInfo[]} availableAudioInputDevices The list of MediaDeviceInfo objects to be used as audio input
* @return {Promise<undefined>}
*/
public async setAvailableAudioInputDevices(availableAudioInputDevices: MediaDeviceInfo[]): Promise<void> {
this._availableAudioInputDevices = availableAudioInputDevices;
return this.setActiveAudioInputDevice(availableAudioInputDevices[0]);
}
/**
* Updates the audio input device in used to the specified one
* @param {MediaDeviceInfo} [audioInputDevice] If no value is specified the default one is selected
* @return {Promise<undefined>}
*/
public async setActiveAudioInputDevice(audioInputDevice?: MediaDeviceInfo): Promise<void> {
this._activeAudioInputDevice = audioInputDevice;
const mediaConstraints = this.getMediaConstraints();
if (audioInputDevice) {
if (mediaConstraints.audio) {
if (typeof mediaConstraints.audio === 'boolean') {
mediaConstraints.audio = {};
}
const deviceId = audioInputDevice.deviceId;
mediaConstraints.audio.deviceId = deviceId;
}
} else if (typeof mediaConstraints.audio === 'object') {
delete mediaConstraints.audio.deviceId;
}
return this.setMediaConstraints(mediaConstraints);
}
/**
* Returns the list of available video input devices
* @return {MediaDeviceInfo[]} A list of MediaDeviceInfo objects that can be used as video input
*/
public getAvailableVideoInputDevices(): MediaDeviceInfo[] {
return this._availableVideoInputDevices;
}
/**
* Replaces the list of available video input devices
* @param {MediaDeviceInfo[]} availableVideoInputDevices
* The list of MediaDeviceInfo objects to be used as audio input
* @return {Promise<undefined>}
*/
public async setAvailableVideoInputDevices(availableVideoInputDevices: MediaDeviceInfo[]): Promise<void> {
this._availableVideoInputDevices = availableVideoInputDevices;
return this.setActiveVideoInputDevice(availableVideoInputDevices[0]);
}
/**
* Updates the video input device in used to the specified one
* @param {MediaDeviceInfo} [videoInputDevice] If no value is specified the default one is selected
* @return {Promise<undefined>}
*/
public async setActiveVideoInputDevice(videoInputDevice?: MediaDeviceInfo): Promise<void> {
this._activeVideoInputDevice = videoInputDevice;
const mediaConstraints = this.getMediaConstraints();
if (videoInputDevice) {
if (mediaConstraints.video) {
if (typeof mediaConstraints.video === 'boolean') {
mediaConstraints.video = {};
} else {
delete mediaConstraints.video.facingMode;
}
const deviceId = videoInputDevice.deviceId;
mediaConstraints.video.deviceId = deviceId;
}
} else if (typeof mediaConstraints.video === 'object') {
delete mediaConstraints.video.deviceId;
}
return this.setMediaConstraints(mediaConstraints);
}
/**
* Returns the list of available audio output devices
* @return {MediaDeviceInfo[]} A list of MediaDeviceInfo objects that can be used as audio output
*/
public getAvailableAudioOutputDevices(): MediaDeviceInfo[] {
return this._availableAudioOutputDevices;
}
/**
* Indicates if it is possible to change to a different camera
* @return {boolean}
*/
public canToggleCamera(): boolean {
return !!(this._availableVideoInputDevices.length > 1 && this._mediaTypes.video && this._mediaConstraints.video);
}
/**
* Changes the local video stream to the next available camera.
* @return {Promise<undefined>}
*/
public async toggleCamera(): Promise<void> {
return this.setActiveVideoInputDevice(this.getNextVideoInputDevice());
}
/** @ignore */
public async init(): Promise<void> {
await this.forceUpdateDevices();
}
/**
* Force the update of the devices list
*/
public async forceUpdateDevices(): Promise<void> {
const devices = await navigator.mediaDevices.enumerateDevices();
this._availableAudioInputDevices = devices.filter(device => device.kind === 'audioinput');
this._availableVideoInputDevices = devices.filter(device => device.kind === 'videoinput');
this._availableAudioOutputDevices = devices.filter(device => device.kind === 'audiooutput');
}
/**
* Return the MediaDeviceInfo of the active audio device
* @returns MediaDeviceInfo
*/
getActiveAudioDevice(): MediaDeviceInfo|undefined {
return this._activeAudioInputDevice;
}
/**
* Return the MediaDeviceInfo of the active video device
* @returns MediaDeviceInfo
*/
getActiveVideoDevice(): MediaDeviceInfo|undefined {
return this._activeVideoInputDevice;
}
/** @private */
private getActiveVideoInputDeviceFromLocalStreams(): MediaDeviceInfo|undefined {
if (!this._availableVideoInputDevices.length) {
return;
}
if (!this._videoStream || !this._videoStream.hasVideoTracks()) {
return this._availableVideoInputDevices[0];
}
const videoTracks = this._videoStream.getVideoTracks();
if (typeof videoTracks[0].getSettings !== 'function') {
return this._availableVideoInputDevices[0];
}
const activeDeviceIds = videoTracks.map((track: MediaStreamTrack) => track.getSettings().deviceId);
return this._availableVideoInputDevices.find(device => activeDeviceIds.includes(device.deviceId));
}
/** @private */
private getNextVideoInputDevice(): MediaDeviceInfo|undefined {
const activeDevice = this._activeVideoInputDevice ?
this._activeVideoInputDevice : this.getActiveVideoInputDeviceFromLocalStreams();
if (!activeDevice) {
return;
}
const getNext = (list: MediaDeviceInfo[], activeEl: MediaDeviceInfo): MediaDeviceInfo =>
list[(list.indexOf(activeEl) + 1) % list.length];
return getNext(this._availableVideoInputDevices, activeDevice);
}
}