Home Reference Source

src/media/LocalMediaHandler.ts

import bowser from 'bowser';
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 */
	private mediaTypes: MediaTypes = {
		audio: true,
		video: true,
		screen: false,
	};
	private mediaConstraints: MediaStreamConstraints;
	private updateMedia: UpdateMedia;
	private videoStream?: ManagedStream;
	private audioOnlyStream?: ManagedStream;

	/** @private */
	private availableAudioInputDevices: MediaDeviceInfo[] = [];
	/** @private */
	private availableAudioOutputDevices: MediaDeviceInfo[] = [];
	/** @private */
	private availableVideoInputDevices: MediaDeviceInfo[] = [];
	private activeVideoInputDevice?: MediaDeviceInfo;

	/** @private */
	private audioMuted = false;
	/** @private */
	private videoMuted = false;
	/** @private */
	private beforeShareScreenVideo = true;

	/** @ignore */
	public constructor(mediaConstraints: MediaStreamConstraints = {audio: true, video: true}, updateMedia: UpdateMedia) {
		/** @private */
		this.mediaConstraints = cloneDeep(mediaConstraints);
		/** @private */
		this.updateMedia = updateMedia;
	}

	/**
	 * Return the media types configured for the call
	 */
	public getMediaTypes(): MediaTypes {
		return clone(this.mediaTypes);
	}

	/**
	 * @desc 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
	 *
	 * @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('*');
	}

	/**
	 * Return the media constraints configured for the call
	 */
	public getMediaConstraints(): MediaStreamConstraints {
		return cloneDeep(this.mediaConstraints);
	}

	/**
	 * @desc 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
	 *
	 * @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('*');
	}

	/**
	 * @desc 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),
		};
	}

	/**
	 * @desc 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) {
			/** @private */
			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) {
			/** @private */
			this.audioOnlyStream = new ManagedStream(stream, true);
		}
		if (!this.videoStream) {
			this.emitter.emit('videoStream');
		}
		this.emitter.emit('*');
	}

	/**
	 * Returns a boolean value indicating if the call has a local audio track
	 */
	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
	 */
	public hasVideoTrack(): boolean {
		return !!this.videoStream && this.videoStream.hasVideoTracks();
	}

	/**
	 * Returns a boolean value indicating if local audio is muted
	 */
	public isAudioMuted(): boolean {
		if (this.audioOnlyStream) {
			return this.audioOnlyStream.isAudioMuted();
		}
		if (this.videoStream) {
			return this.videoStream.isAudioMuted();
		}
		return true;
	}

	/**
	 * @desc 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
	 */
	public isVideoMuted(): boolean {
		return !!this.videoStream && this.videoStream.isVideoMuted();
	}

	/**
	 * @desc Mutes or unmutes the video. If no parameter is specified it toggles current value.
	 * @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
	 */
	public canToggleVideo(): boolean {
		return this.availableVideoInputDevices.length > 0 && !this.mediaTypes.screen;
	}

	/**
	 * @desc Enables or disables the video. If no parameter is specified it toggles current value.
	 * @param {boolean} [video] If specified forces video to be enabled or not
	 * @return {Promise}
	 */
	public toggleVideo(video = !this.mediaTypes.video): Promise<void> {
		const {audio, screen} = this.mediaTypes;
		return this.setMediaTypes({audio, video, screen});
	}

	/**
	 * @desc 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.
	 */
	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;
		}
		try {
			await this.setMediaTypes({audio, video, screen});
		} catch (error) {
			switch (error.message) {
				case 'chrome-screensharing-plugin-missing':
					throw new Error('plugin-missing-chrome');
				case 'firefox-screensharing-plugin-missing':
					throw new Error('plugin-missing-firefox');
				case 'user-cancel':
					throw new Error('user-cancel');
				default:
					throw error;
			}
		}
	}

	/**
	 * @desc 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<void>}
	 */
	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,
		});
	}

	/**
	 * @desc 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;
	}

	/**
	 * @desc Replaces the list of available audio input devices
	 * @param {MediaDeviceInfo[]} availableAudioInputDevices The list of MediaDeviceInfo objects to be used as audio input
	 * @return {Promise}
	 */
	public setAvailableAudioInputDevices(availableAudioInputDevices: MediaDeviceInfo[]): Promise<void> {
		this.availableAudioInputDevices = availableAudioInputDevices;
		return this.setActiveAudioInputDevice(availableAudioInputDevices[0]);
	}

	/**
	 * @desc 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}
	 */
	public setActiveAudioInputDevice(audioInputDevice?: MediaDeviceInfo): Promise<void> {
		const mediaConstraints = this.getMediaConstraints();
		if (audioInputDevice) {
			if (mediaConstraints.audio) {
				if (typeof mediaConstraints.audio === 'boolean') {
					mediaConstraints.audio = {};
				}
				const deviceId = audioInputDevice.deviceId;
				mediaConstraints.audio.deviceId = bowser.ios ? deviceId : {exact: deviceId};
			}
		} else if (typeof mediaConstraints.audio === 'object') {
			delete mediaConstraints.audio.deviceId;
		}
		return this.setMediaConstraints(mediaConstraints);
	}

	/**
	 * @desc 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;
	}

	/**
	 * @desc Replaces the list of available video input devices
	 * @param {MediaDeviceInfo[]} availableVideoInputDevices
	 * The list of MediaDeviceInfo objects to be used as audio input
	 * @return {Promise}
	 */
	public setAvailableVideoInputDevices(availableVideoInputDevices: MediaDeviceInfo[]): Promise<void> {
		this.availableVideoInputDevices = availableVideoInputDevices;
		return this.setActiveVideoInputDevice(availableVideoInputDevices[0]);
	}

	/**
	 * @desc 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}
	 */
	public setActiveVideoInputDevice(videoInputDevice?: MediaDeviceInfo): Promise<void> {
		/** @private */
		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 = bowser.ios ? deviceId : {exact: deviceId};
			}
		} else if (typeof mediaConstraints.audio === 'object') {
			delete mediaConstraints.audio.deviceId;
		}
		return this.setMediaConstraints(mediaConstraints);
	}

	/**
	 * @desc 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
	 */
	public canToggleCamera(): boolean {
		return !!(this.availableVideoInputDevices.length > 1 && this.mediaTypes.video && this.mediaConstraints.video);
	}

	/**
	 * Changes the local video stream to the next available camera.
	 */
	public toggleCamera(): Promise<void> {
		return this.setActiveVideoInputDevice(this.getNextVideoInputDevice());
	}

	/** @ignore */
	public async init(): Promise<void> {
		await this.initializeDevices();
	}

	/** @private */
	private async initializeDevices(): 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');
	}

	/** @private */
	private getActiveVideoInputDeviceFromLocalStreams(): MediaDeviceInfo|undefined {
		if (!this.availableVideoInputDevices.length) {
			return;
		}
		if (!this.videoStream || !this.videoStream.hasCameraTracks()) {
			return this.availableVideoInputDevices[0];
		}
		const videoTracks = this.videoStream.getCameraTracks();
		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);
	}
}