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