Home Reference Source

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);
	}
}