multiparty-meeting/app/lib/RoomClient.js

2000 lines
43 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

import protooClient from 'protoo-client';
import * as mediasoupClient from 'mediasoup-client';
import Logger from './Logger';
import hark from 'hark';
import ScreenShare from './ScreenShare';
import { getProtooUrl } from './urlFactory';
import * as cookiesManager from './cookiesManager';
import * as requestActions from './redux/requestActions';
import * as stateActions from './redux/stateActions';
import {
turnServers,
requestTimeout,
transportOptions
} from '../config';
const logger = new Logger('RoomClient');
const ROOM_OPTIONS =
{
requestTimeout : requestTimeout,
transportOptions : transportOptions,
turnServers : turnServers
};
const VIDEO_CONSTRAINS =
{
width : { ideal: 1280 },
aspectRatio : 1.334
};
export default class RoomClient
{
constructor(
{ roomId, peerName, displayName, device, useSimulcast, produce, dispatch, getState })
{
logger.debug(
'constructor() [roomId:"%s", peerName:"%s", displayName:"%s", device:%s]',
roomId, peerName, displayName, device.flag);
const protooUrl = getProtooUrl(peerName, roomId);
const protooTransport = new protooClient.WebSocketTransport(protooUrl);
// window element to external login site
this._loginWindow;
// Closed flag.
this._closed = false;
// Whether we should produce.
this._produce = produce;
// Whether simulcast should be used.
this._useSimulcast = useSimulcast;
// Redux store dispatch function.
this._dispatch = dispatch;
// Redux store getState function.
this._getState = getState;
// This device
this._device = device;
// My peer name.
this._peerName = peerName;
// protoo-client Peer instance.
this._protoo = new protooClient.Peer(protooTransport);
// mediasoup-client Room instance.
this._room = new mediasoupClient.Room(ROOM_OPTIONS);
this._room.roomId = roomId;
// Transport for sending.
this._sendTransport = null;
// Transport for receiving.
this._recvTransport = null;
// Local mic mediasoup Producer.
this._micProducer = null;
// Local webcam mediasoup Producer.
this._webcamProducer = null;
// Map of webcam MediaDeviceInfos indexed by deviceId.
// @type {Map<String, MediaDeviceInfos>}
this._webcams = new Map();
this._audioDevices = new Map();
// Local Webcam. Object with:
// - {MediaDeviceInfo} [device]
// - {String} [resolution] - 'qvga' / 'vga' / 'hd'.
this._webcam = {
device : null,
resolution : 'hd'
};
this._audioDevice = {
device : null
};
this._screenSharing = ScreenShare.create();
this._screenSharingProducer = null;
this._join({ displayName, device });
}
close()
{
if (this._closed)
return;
this._closed = true;
logger.debug('close()');
// Leave the mediasoup Room.
this._room.leave();
// Close protoo Peer (wait a bit so mediasoup-client can send
// the 'leaveRoom' notification).
setTimeout(() => this._protoo.close(), 250);
this._dispatch(stateActions.setRoomState('closed'));
}
login()
{
const url = `/login?roomId=${this._room.roomId}&peerName=${this._peerName}`;
this._loginWindow = window.open(url, 'loginWindow');
}
logout()
{
window.location = '/logout';
}
closeLoginWindow()
{
this._loginWindow.close();
}
changeDisplayName(displayName)
{
logger.debug('changeDisplayName() [displayName:"%s"]', displayName);
// Store in cookie.
cookiesManager.setUser({ displayName });
return this._protoo.send('change-display-name', { displayName })
.then(() =>
{
this._dispatch(
stateActions.setDisplayName(displayName));
this._dispatch(requestActions.notify(
{
text : 'Display name changed'
}));
})
.catch((error) =>
{
logger.error('changeDisplayName() | failed: %o', error);
this._dispatch(requestActions.notify(
{
type : 'error',
text : `Could not change display name: ${error}`
}));
// We need to refresh the component for it to render the previous
// displayName again.
this._dispatch(stateActions.setDisplayName());
});
}
changeProfilePicture(picture)
{
logger.debug('changeProfilePicture() [picture: "%s"]', picture);
this._protoo.send('change-profile-picture', { picture }).catch((error) =>
{
logger.error('shareProfilePicure() | failed: %o', error);
});
}
sendChatMessage(chatMessage)
{
logger.debug('sendChatMessage() [chatMessage:"%s"]', chatMessage);
return this._protoo.send('chat-message', { chatMessage })
.catch((error) =>
{
logger.error('sendChatMessage() | failed: %o', error);
this._dispatch(requestActions.notify(
{
type : 'error',
text : `Could not send chat: ${error}`
}));
});
}
sendFile(file)
{
logger.debug('sendFile() [file: %o]', file);
return this._protoo.send('send-file', { file })
.catch((error) =>
{
logger.error('sendFile() | failed: %o', error);
this._dispatch(requestActions.notify({
typ: 'error',
text: 'An error occurred while sharing a file'
}));
});
}
getChatHistory()
{
logger.debug('getChatHistory()');
return this._protoo.send('chat-history', {})
.catch((error) =>
{
logger.error('getChatHistory() | failed: %o', error);
this._dispatch(requestActions.notify(
{
type : 'error',
text : `Could not get chat history: ${error}`
}));
});
}
muteMic()
{
logger.debug('muteMic()');
this._micProducer.pause();
}
unmuteMic()
{
logger.debug('unmuteMic()');
this._micProducer.resume();
}
installExtension()
{
logger.debug('installExtension()');
return new Promise((resolve, reject) =>
{
window.addEventListener('message', _onExtensionMessage, false);
// eslint-disable-next-line no-undef
chrome.webstore.install(null, _successfulInstall, _failedInstall);
function _onExtensionMessage({ data })
{
if (data.type === 'ScreenShareInjected')
{
logger.debug('installExtension() | installation succeeded');
return resolve();
}
}
function _failedInstall(reason)
{
window.removeEventListener('message', _onExtensionMessage);
return reject(
new Error('Failed to install extension: %s', reason));
}
function _successfulInstall()
{
logger.debug('installExtension() | installation accepted');
}
})
.then(() =>
{
// This should be handled better
this._dispatch(stateActions.setScreenCapabilities(
{
canShareScreen : this._room.canSend('video'),
needExtension : false
}));
})
.catch((error) =>
{
logger.error('installExtension() | failed: %o', error);
});
}
enableScreenSharing()
{
logger.debug('enableScreenSharing()');
this._dispatch(
stateActions.setScreenShareInProgress(true));
return Promise.resolve()
.then(() =>
{
return this._setScreenShareProducer();
})
.then(() =>
{
this._dispatch(
stateActions.setScreenShareInProgress(false));
})
.catch((error) =>
{
logger.error('enableScreenSharing() | failed: %o', error);
this._dispatch(
stateActions.setScreenShareInProgress(false));
});
}
enableWebcam()
{
logger.debug('enableWebcam()');
// Store in cookie.
cookiesManager.setDevices({ webcamEnabled: true });
this._dispatch(
stateActions.setWebcamInProgress(true));
return Promise.resolve()
.then(() =>
{
return this._updateWebcams();
})
.then(() =>
{
return this._setWebcamProducer();
})
.then(() =>
{
this._dispatch(
stateActions.setWebcamInProgress(false));
})
.catch((error) =>
{
logger.error('enableWebcam() | failed: %o', error);
this._dispatch(
stateActions.setWebcamInProgress(false));
});
}
disableScreenSharing()
{
logger.debug('disableScreenSharing()');
this._dispatch(
stateActions.setScreenShareInProgress(true));
return Promise.resolve()
.then(() =>
{
this._screenSharingProducer.close();
this._dispatch(
stateActions.setScreenShareInProgress(false));
})
.catch((error) =>
{
logger.error('disableScreenSharing() | failed: %o', error);
this._dispatch(
stateActions.setScreenShareInProgress(false));
});
}
disableWebcam()
{
logger.debug('disableWebcam()');
// Store in cookie.
cookiesManager.setDevices({ webcamEnabled: false });
this._dispatch(
stateActions.setWebcamInProgress(true));
return Promise.resolve()
.then(() =>
{
this._webcamProducer.close();
this._dispatch(
stateActions.setWebcamInProgress(false));
})
.catch((error) =>
{
logger.error('disableWebcam() | failed: %o', error);
this._dispatch(
stateActions.setWebcamInProgress(false));
});
}
changeAudioDevice(deviceId)
{
logger.debug('changeAudioDevice() [deviceId: %s]', deviceId);
this._dispatch(
stateActions.setAudioInProgress(true));
return Promise.resolve()
.then(() =>
{
this._audioDevice.device = this._audioDevices.get(deviceId);
logger.debug(
'changeAudioDevice() | new selected webcam [device:%o]',
this._audioDevice.device);
})
.then(() =>
{
const { device } = this._audioDevice;
if (!device)
throw new Error('no audio devices');
logger.debug('changeAudioDevice() | calling getUserMedia()');
return navigator.mediaDevices.getUserMedia(
{
audio :
{
deviceId : { exact: device.deviceId }
}
});
})
.then((stream) =>
{
const track = stream.getAudioTracks()[0];
return this._micProducer.replaceTrack(track)
.then((newTrack) =>
{
track.stop();
return newTrack;
});
})
.then((newTrack) =>
{
this._dispatch(
stateActions.setProducerTrack(this._micProducer.id, newTrack));
return this._updateAudioDevices();
})
.then(() =>
{
this._dispatch(
stateActions.setAudioInProgress(false));
})
.catch((error) =>
{
logger.error('changeAudioDevice() failed: %o', error);
this._dispatch(
stateActions.setAudioInProgress(false));
});
}
changeWebcam(deviceId)
{
logger.debug('changeWebcam() [deviceId: %s]', deviceId);
this._dispatch(
stateActions.setWebcamInProgress(true));
return Promise.resolve()
.then(() =>
{
this._webcam.device = this._webcams.get(deviceId);
logger.debug(
'changeWebcam() | new selected webcam [device:%o]',
this._webcam.device);
// Reset video resolution to HD.
this._webcam.resolution = 'hd';
})
.then(() =>
{
const { device } = this._webcam;
if (!device)
throw new Error('no webcam devices');
logger.debug('changeWebcam() | calling getUserMedia()');
return navigator.mediaDevices.getUserMedia(
{
video :
{
deviceId : { exact: device.deviceId },
...VIDEO_CONSTRAINS
}
});
})
.then((stream) =>
{
const track = stream.getVideoTracks()[0];
return this._webcamProducer.replaceTrack(track)
.then((newTrack) =>
{
track.stop();
return newTrack;
});
})
.then((newTrack) =>
{
this._dispatch(
stateActions.setProducerTrack(this._webcamProducer.id, newTrack));
return this._updateWebcams();
})
.then(() =>
{
this._dispatch(
stateActions.setWebcamInProgress(false));
})
.catch((error) =>
{
logger.error('changeWebcam() failed: %o', error);
this._dispatch(
stateActions.setWebcamInProgress(false));
});
}
changeWebcamResolution()
{
logger.debug('changeWebcamResolution()');
let oldResolution;
let newResolution;
this._dispatch(
stateActions.setWebcamInProgress(true));
return Promise.resolve()
.then(() =>
{
oldResolution = this._webcam.resolution;
switch (oldResolution)
{
case 'qvga':
newResolution = 'vga';
break;
case 'vga':
newResolution = 'hd';
break;
case 'hd':
newResolution = 'qvga';
break;
}
this._webcam.resolution = newResolution;
})
.then(() =>
{
const { device } = this._webcam;
logger.debug('changeWebcamResolution() | calling getUserMedia()');
return navigator.mediaDevices.getUserMedia(
{
video :
{
deviceId : { exact: device.deviceId },
...VIDEO_CONSTRAINS
}
});
})
.then((stream) =>
{
const track = stream.getVideoTracks()[0];
return this._webcamProducer.replaceTrack(track)
.then((newTrack) =>
{
track.stop();
return newTrack;
});
})
.then((newTrack) =>
{
this._dispatch(
stateActions.setProducerTrack(this._webcamProducer.id, newTrack));
this._dispatch(
stateActions.setWebcamInProgress(false));
})
.catch((error) =>
{
logger.error('changeWebcamResolution() failed: %o', error);
this._dispatch(
stateActions.setWebcamInProgress(false));
this._webcam.resolution = oldResolution;
});
}
mutePeerAudio(peerName)
{
logger.debug('mutePeerAudio() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'mic')
continue;
consumer.pause('mute-audio');
}
}
}
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('mutePeerAudio() failed: %o', error);
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, false));
});
}
unmutePeerAudio(peerName)
{
logger.debug('unmutePeerAudio() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'mic' || !consumer.supported)
continue;
consumer.resume();
}
}
}
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('unmutePeerAudio() failed: %o', error);
this._dispatch(
stateActions.setPeerAudioInProgress(peerName, false));
});
}
pausePeerVideo(peerName)
{
logger.debug('pausePeerVideo() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'webcam')
continue;
consumer.pause('pause-video');
}
}
}
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('pausePeerVideo() failed: %o', error);
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, false));
});
}
resumePeerVideo(peerName)
{
logger.debug('resumePeerVideo() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'webcam' || !consumer.supported)
continue;
consumer.resume();
}
}
}
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('resumePeerVideo() failed: %o', error);
this._dispatch(
stateActions.setPeerVideoInProgress(peerName, false));
});
}
pausePeerScreen(peerName)
{
logger.debug('pausePeerScreen() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'screen')
continue;
consumer.pause('pause-screen');
}
}
}
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('pausePeerScreen() failed: %o', error);
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, false));
});
}
resumePeerScreen(peerName)
{
logger.debug('resumePeerScreen() [peerName:"%s"]', peerName);
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, true));
return Promise.resolve()
.then(() =>
{
for (const peer of this._room.peers)
{
if (peer.name === peerName)
{
for (const consumer of peer.consumers)
{
if (consumer.appData.source !== 'screen' || !consumer.supported)
continue;
consumer.resume();
}
}
}
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, false));
})
.catch((error) =>
{
logger.error('resumePeerScreen() failed: %o', error);
this._dispatch(
stateActions.setPeerScreenInProgress(peerName, false));
});
}
enableAudioOnly()
{
logger.debug('enableAudioOnly()');
this._dispatch(
stateActions.setAudioOnlyInProgress(true));
return Promise.resolve()
.then(() =>
{
if (this._webcamProducer)
this._webcamProducer.close();
for (const peer of this._room.peers)
{
for (const consumer of peer.consumers)
{
if (consumer.kind !== 'video')
continue;
consumer.pause('audio-only-mode');
}
}
this._dispatch(
stateActions.setAudioOnlyState(true));
this._dispatch(
stateActions.setAudioOnlyInProgress(false));
})
.catch((error) =>
{
logger.error('enableAudioOnly() failed: %o', error);
this._dispatch(
stateActions.setAudioOnlyInProgress(false));
});
}
disableAudioOnly()
{
logger.debug('disableAudioOnly()');
this._dispatch(
stateActions.setAudioOnlyInProgress(true));
return Promise.resolve()
.then(() =>
{
if (!this._webcamProducer && this._room.canSend('video'))
return this.enableWebcam();
})
.then(() =>
{
for (const peer of this._room.peers)
{
for (const consumer of peer.consumers)
{
if (consumer.kind !== 'video' || !consumer.supported)
continue;
consumer.resume();
}
}
this._dispatch(
stateActions.setAudioOnlyState(false));
this._dispatch(
stateActions.setAudioOnlyInProgress(false));
})
.catch((error) =>
{
logger.error('disableAudioOnly() failed: %o', error);
this._dispatch(
stateActions.setAudioOnlyInProgress(false));
});
}
sendRaiseHandState(state)
{
logger.debug('sendRaiseHandState: ', state);
this._dispatch(
stateActions.setMyRaiseHandStateInProgress(true));
return this._protoo.send('raisehand-message', { raiseHandState: state })
.then(() =>
{
this._dispatch(
stateActions.setMyRaiseHandState(state));
this._dispatch(
stateActions.setMyRaiseHandStateInProgress(false));
})
.catch((error) =>
{
logger.error('sendRaiseHandState() | failed: %o', error);
this._dispatch(requestActions.notify(
{
type : 'error',
text : `Could not change raise hand state: ${error}`
}));
// We need to refresh the component for it to render changed state
this._dispatch(stateActions.setMyRaiseHandState(!state));
this._dispatch(
stateActions.setMyRaiseHandStateInProgress(false));
});
}
restartIce()
{
logger.debug('restartIce()');
this._dispatch(
stateActions.setRestartIceInProgress(true));
return Promise.resolve()
.then(() =>
{
this._room.restartIce();
// Make it artificially longer.
setTimeout(() =>
{
this._dispatch(
stateActions.setRestartIceInProgress(false));
}, 500);
})
.catch((error) =>
{
logger.error('restartIce() failed: %o', error);
this._dispatch(
stateActions.setRestartIceInProgress(false));
});
}
_join({ displayName, device })
{
this._dispatch(stateActions.setRoomState('connecting'));
this._protoo.on('open', () =>
{
logger.debug('protoo Peer "open" event');
this._joinRoom({ displayName, device });
});
this._protoo.on('disconnected', () =>
{
logger.warn('protoo Peer "disconnected" event');
this._dispatch(requestActions.notify(
{
type : 'error',
text : 'WebSocket disconnected'
}));
// Leave Room.
try { this._room.remoteClose({ cause: 'protoo disconnected' }); }
catch (error) {}
this._dispatch(stateActions.setRoomState('connecting'));
});
this._protoo.on('close', () =>
{
if (this._closed)
return;
logger.warn('protoo Peer "close" event');
this.close();
});
this._protoo.on('request', (request, accept, reject) =>
{
logger.debug(
'_handleProtooRequest() [method:%s, data:%o]',
request.method, request.data);
switch (request.method)
{
case 'mediasoup-notification':
{
accept();
const notification = request.data;
this._room.receiveNotification(notification);
break;
}
case 'active-speaker':
{
accept();
const { peerName } = request.data;
this._dispatch(
stateActions.setRoomActiveSpeaker(peerName));
break;
}
case 'display-name-changed':
{
accept();
// eslint-disable-next-line no-shadow
const { peerName, displayName, oldDisplayName } = request.data;
// NOTE: Hack, we shouldn't do this, but this is just a demo.
const peer = this._room.getPeerByName(peerName);
if (!peer)
{
logger.error('peer not found');
break;
}
peer.appData.displayName = displayName;
this._dispatch(
stateActions.setPeerDisplayName(displayName, peerName));
this._dispatch(requestActions.notify(
{
text : `${oldDisplayName} is now ${displayName}`
}));
break;
}
case 'profile-picture-changed':
{
accept();
const { peerName, picture } = request.data;
this._dispatch(stateActions.setPeerPicture(peerName, picture));
break;
}
// This means: server wants to change MY user information
case 'auth':
{
logger.debug('got auth event from server', request.data);
accept();
this.changeDisplayName(request.data.name);
this.changeProfilePicture(request.data.picture);
this._dispatch(stateActions.setPicture(request.data.picture));
this._dispatch(stateActions.loggedIn());
this._dispatch(requestActions.notify(
{
text : `Authenticated successfully: ${request.data}`
}
));
this.closeLoginWindow();
break;
}
case 'raisehand-message':
{
accept();
const { peerName, raiseHandState } = request.data;
logger.debug('Got raiseHandState from "%s"', peerName);
this._dispatch(
stateActions.setPeerRaiseHandState(peerName, raiseHandState));
break;
}
case 'chat-message-receive':
{
accept();
const { peerName, chatMessage } = request.data;
logger.debug('Got chat from "%s"', peerName);
this._dispatch(
stateActions.addResponseMessage({ ...chatMessage, peerName }));
const toolAreaState = this._getState().toolarea;
// Notify about the new file, unless the chat is open.
if (chatMessage.file && !(toolAreaState.toolAreaOpen && toolAreaState.currentToolTab === 'chat'))
{
this._dispatch(
requestActions.notify({ text: `${chatMessage.name} shared a file` })
);
}
break;
}
case 'chat-history-receive':
{
accept();
const { chatHistory } = request.data;
if (chatHistory.length > 0)
{
logger.debug('Got chat history');
this._dispatch(
stateActions.addChatHistory(chatHistory));
}
break;
}
case 'file-receive':
{
accept();
const { file } = request.data;
this._dispatch(stateActions.addFile(file));
break;
}
default:
{
logger.error('unknown protoo method "%s"', request.method);
reject(404, 'unknown method');
}
}
});
}
_joinRoom({ displayName, device })
{
logger.debug('_joinRoom()');
// NOTE: We allow rejoining (room.join()) the same mediasoup Room when Protoo
// WebSocket re-connects, so we must clean existing event listeners. Otherwise
// they will be called twice after the reconnection.
this._room.removeAllListeners();
this._room.on('close', (originator, appData) =>
{
if (originator === 'remote')
{
logger.warn('mediasoup Peer/Room remotely closed [appData:%o]', appData);
this._dispatch(stateActions.setRoomState('closed'));
return;
}
});
this._room.on('request', (request, callback, errback) =>
{
logger.debug(
'sending mediasoup request [method:%s]:%o', request.method, request);
this._protoo.send('mediasoup-request', request)
.then(callback)
.catch(errback);
});
this._room.on('notify', (notification) =>
{
logger.debug(
'sending mediasoup notification [method:%s]:%o',
notification.method, notification);
this._protoo.send('mediasoup-notification', notification)
.catch((error) =>
{
logger.warn('could not send mediasoup notification:%o', error);
});
});
this._room.on('newpeer', (peer) =>
{
logger.debug(
'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer);
this._handlePeer(peer);
});
this._room.join(this._peerName, { displayName, device })
.then(() =>
{
// Create Transport for sending.
this._sendTransport =
this._room.createTransport('send', { media: 'SEND_MIC_WEBCAM' });
this._sendTransport.on('close', (originator) =>
{
logger.debug(
'Transport "close" event [originator:%s]', originator);
});
// Create Transport for receiving.
this._recvTransport =
this._room.createTransport('recv', { media: 'RECV' });
this._recvTransport.on('close', (originator) =>
{
logger.debug(
'receiving Transport "close" event [originator:%s]', originator);
});
})
.then(() =>
{
// Set our media capabilities.
this._dispatch(stateActions.setMediaCapabilities(
{
canSendMic : this._room.canSend('audio'),
canSendWebcam : this._room.canSend('video')
}));
this._dispatch(stateActions.setScreenCapabilities(
{
canShareScreen : this._room.canSend('video') &&
this._screenSharing.isScreenShareAvailable(),
needExtension : this._screenSharing.needExtension()
}));
})
.then(() =>
{
// Don't produce if explicitely requested to not to do it.
if (!this._produce)
return;
// NOTE: Don't depend on this Promise to continue (so we don't do return).
Promise.resolve()
// Add our mic.
.then(() =>
{
if (!this._room.canSend('audio'))
return;
this._setMicProducer()
.catch(() => {});
})
// Add our webcam (unless the cookie says no).
.then(() =>
{
if (!this._room.canSend('video'))
return;
const devicesCookie = cookiesManager.getDevices();
if (!devicesCookie || devicesCookie.webcamEnabled)
this.enableWebcam();
});
})
.then(() =>
{
this._dispatch(stateActions.setRoomState('connected'));
// Clean all the existing notifcations.
this._dispatch(stateActions.removeAllNotifications());
this.getChatHistory();
this._dispatch(requestActions.notify(
{
text : 'You are in the room',
timeout : 5000
}));
const peers = this._room.peers;
for (const peer of peers)
{
this._handlePeer(peer, { notify: false });
}
})
.catch((error) =>
{
logger.error('_joinRoom() failed:%o', error);
this._dispatch(requestActions.notify(
{
type : 'error',
text : `Could not join the room: ${error.toString()}`
}));
this.close();
});
}
_setMicProducer()
{
if (!this._room.canSend('audio'))
{
return Promise.reject(
new Error('cannot send audio'));
}
if (this._micProducer)
{
return Promise.reject(
new Error('mic Producer already exists'));
}
let producer;
return Promise.resolve()
.then(() =>
{
logger.debug('_setMicProducer() | calling _updateAudioDevices()');
return this._updateAudioDevices();
})
.then(() =>
{
logger.debug('_setMicProducer() | calling getUserMedia()');
return navigator.mediaDevices.getUserMedia({ audio: true });
})
.then((stream) =>
{
const track = stream.getAudioTracks()[0];
producer = this._room.createProducer(track, null, { source: 'mic' });
// No need to keep original track.
track.stop();
// Send it.
return producer.send(this._sendTransport);
})
.then(() =>
{
this._micProducer = producer;
this._dispatch(stateActions.addProducer(
{
id : producer.id,
source : 'mic',
locallyPaused : producer.locallyPaused,
remotelyPaused : producer.remotelyPaused,
track : producer.track,
codec : producer.rtpParameters.codecs[0].name
}));
producer.on('close', (originator) =>
{
logger.debug(
'mic Producer "close" event [originator:%s]', originator);
this._micProducer = null;
this._dispatch(stateActions.removeProducer(producer.id));
});
producer.on('pause', (originator) =>
{
logger.debug(
'mic Producer "pause" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerPaused(producer.id, originator));
});
producer.on('resume', (originator) =>
{
logger.debug(
'mic Producer "resume" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerResumed(producer.id, originator));
});
producer.on('handled', () =>
{
logger.debug('mic Producer "handled" event');
});
producer.on('unhandled', () =>
{
logger.debug('mic Producer "unhandled" event');
});
})
.then(() =>
{
const stream = new MediaStream;
logger.debug('_setMicProducer() succeeded');
stream.addTrack(producer.track);
if (!stream.getAudioTracks()[0])
throw new Error('_setMicProducer(): given stream has no audio track');
producer.hark = hark(stream, { play: false });
// eslint-disable-next-line no-unused-vars
producer.hark.on('volume_change', (dBs, threshold) =>
{
// The exact formula to convert from dBs (-100..0) to linear (0..1) is:
// Math.pow(10, dBs / 20)
// However it does not produce a visually useful output, so let exagerate
// it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to
// minimize component renderings.
let volume = Math.round(Math.pow(10, dBs / 85) * 10);
if (volume === 1)
volume = 0;
if (volume !== producer.volume)
{
producer.volume = volume;
this._dispatch(stateActions.setProducerVolume(producer.id, volume));
}
});
})
.catch((error) =>
{
logger.error('_setMicProducer() failed:%o', error);
this._dispatch(requestActions.notify(
{
text : `Mic producer failed: ${error.name}:${error.message}`
}));
if (producer)
producer.close();
throw error;
});
}
_setScreenShareProducer()
{
if (!this._room.canSend('video'))
{
return Promise.reject(
new Error('cannot send screen'));
}
let producer;
return Promise.resolve()
.then(() =>
{
const available = this._screenSharing.isScreenShareAvailable() &&
!this._screenSharing.needExtension();
if (!available)
throw new Error('screen sharing not available');
logger.debug('_setScreenShareProducer() | calling getUserMedia()');
return this._screenSharing.start({
width : 1280,
height : 720,
frameRate : 3
});
})
.then((stream) =>
{
const track = stream.getVideoTracks()[0];
producer = this._room.createProducer(
track, { simulcast: false }, { source: 'screen' });
// No need to keep original track.
track.stop();
// Send it.
return producer.send(this._sendTransport);
})
.then(() =>
{
this._screenSharingProducer = producer;
this._dispatch(stateActions.addProducer(
{
id : producer.id,
source : 'screen',
deviceLabel : 'screen',
type : 'screen',
locallyPaused : producer.locallyPaused,
remotelyPaused : producer.remotelyPaused,
track : producer.track,
codec : producer.rtpParameters.codecs[0].name
}));
producer.on('close', (originator) =>
{
logger.debug(
'webcam Producer "close" event [originator:%s]', originator);
this._screenSharingProducer = null;
this._dispatch(stateActions.removeProducer(producer.id));
});
producer.on('trackended', (originator) =>
{
logger.debug(
'webcam Producer "trackended" event [originator:%s]', originator);
this.disableScreenSharing();
});
producer.on('pause', (originator) =>
{
logger.debug(
'webcam Producer "pause" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerPaused(producer.id, originator));
});
producer.on('resume', (originator) =>
{
logger.debug(
'webcam Producer "resume" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerResumed(producer.id, originator));
});
producer.on('handled', () =>
{
logger.debug('webcam Producer "handled" event');
});
producer.on('unhandled', () =>
{
logger.debug('webcam Producer "unhandled" event');
});
})
.then(() =>
{
logger.debug('_setScreenShareProducer() succeeded');
})
.catch((error) =>
{
logger.error('_setScreenShareProducer() failed:%o', error);
this._dispatch(requestActions.notify(
{
text : `Screen share producer failed: ${error.name}:${error.message}`
}));
if (producer)
producer.close();
throw error;
});
}
_setWebcamProducer()
{
if (!this._room.canSend('video'))
{
return Promise.reject(
new Error('cannot send video'));
}
if (this._webcamProducer)
{
return Promise.reject(
new Error('webcam Producer already exists'));
}
let producer;
return Promise.resolve()
.then(() =>
{
const { device } = this._webcam;
if (!device)
throw new Error('no webcam devices');
logger.debug('_setWebcamProducer() | calling getUserMedia()');
return navigator.mediaDevices.getUserMedia(
{
video :
{
deviceId : { exact: device.deviceId },
...VIDEO_CONSTRAINS
}
});
})
.then((stream) =>
{
const track = stream.getVideoTracks()[0];
producer = this._room.createProducer(
track, { simulcast: this._useSimulcast }, { source: 'webcam' });
// No need to keep original track.
track.stop();
// Send it.
return producer.send(this._sendTransport);
})
.then(() =>
{
this._webcamProducer = producer;
const { device } = this._webcam;
this._dispatch(stateActions.addProducer(
{
id : producer.id,
source : 'webcam',
deviceLabel : device.label,
type : this._getWebcamType(device),
locallyPaused : producer.locallyPaused,
remotelyPaused : producer.remotelyPaused,
track : producer.track,
codec : producer.rtpParameters.codecs[0].name
}));
producer.on('close', (originator) =>
{
logger.debug(
'webcam Producer "close" event [originator:%s]', originator);
this._webcamProducer = null;
this._dispatch(stateActions.removeProducer(producer.id));
});
producer.on('pause', (originator) =>
{
logger.debug(
'webcam Producer "pause" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerPaused(producer.id, originator));
});
producer.on('resume', (originator) =>
{
logger.debug(
'webcam Producer "resume" event [originator:%s]', originator);
this._dispatch(stateActions.setProducerResumed(producer.id, originator));
});
producer.on('handled', () =>
{
logger.debug('webcam Producer "handled" event');
});
producer.on('unhandled', () =>
{
logger.debug('webcam Producer "unhandled" event');
});
})
.then(() =>
{
logger.debug('_setWebcamProducer() succeeded');
})
.catch((error) =>
{
logger.error('_setWebcamProducer() failed:%o', error);
this._dispatch(requestActions.notify(
{
text : `Webcam producer failed: ${error.name}:${error.message}`
}));
if (producer)
producer.close();
throw error;
});
}
_updateAudioDevices()
{
logger.debug('_updateAudioDevices()');
// Reset the list.
this._audioDevices = new Map();
return Promise.resolve()
.then(() =>
{
logger.debug('_updateAudioDevices() | calling enumerateDevices()');
return navigator.mediaDevices.enumerateDevices();
})
.then((devices) =>
{
for (const device of devices)
{
if (device.kind !== 'audioinput')
continue;
device.value = device.deviceId;
this._audioDevices.set(device.deviceId, device);
}
})
.then(() =>
{
const array = Array.from(this._audioDevices.values());
const len = array.length;
const currentAudioDeviceId =
this._audioDevice.device ? this._audioDevice.device.deviceId : undefined;
logger.debug('_updateAudioDevices() [audiodevices:%o]', array);
if (len === 0)
this._audioDevice.device = null;
else if (!this._audioDevices.has(currentAudioDeviceId))
this._audioDevice.device = array[0];
this._dispatch(
stateActions.setCanChangeWebcam(this._webcams.size >= 2));
this._dispatch(
stateActions.setCanChangeAudioDevice(len >= 2));
if (len >= 1)
this._dispatch(
stateActions.setAudioDevices(this._audioDevices));
});
}
_updateWebcams()
{
logger.debug('_updateWebcams()');
// Reset the list.
this._webcams = new Map();
return Promise.resolve()
.then(() =>
{
logger.debug('_updateWebcams() | calling enumerateDevices()');
return navigator.mediaDevices.enumerateDevices();
})
.then((devices) =>
{
for (const device of devices)
{
if (device.kind !== 'videoinput')
continue;
device.value = device.deviceId;
this._webcams.set(device.deviceId, device);
}
})
.then(() =>
{
const array = Array.from(this._webcams.values());
const len = array.length;
const currentWebcamId =
this._webcam.device ? this._webcam.device.deviceId : undefined;
logger.debug('_updateWebcams() [webcams:%o]', array);
if (len === 0)
this._webcam.device = null;
else if (!this._webcams.has(currentWebcamId))
this._webcam.device = array[0];
this._dispatch(
stateActions.setCanChangeWebcam(this._webcams.size >= 2));
this._dispatch(
stateActions.setCanChangeWebcam(len >= 2));
if (len >= 1)
this._dispatch(
stateActions.setWebcamDevices(this._webcams));
});
}
_getWebcamType(device)
{
if (/(back|rear)/i.test(device.label))
{
logger.debug('_getWebcamType() | it seems to be a back camera');
return 'back';
}
else
{
logger.debug('_getWebcamType() | it seems to be a front camera');
return 'front';
}
}
_handlePeer(peer, { notify = true } = {})
{
const displayName = peer.appData.displayName;
this._dispatch(stateActions.addPeer(
{
name : peer.name,
displayName : displayName,
device : peer.appData.device,
raiseHandState : peer.appData.raiseHandState,
consumers : []
}));
if (notify)
{
this._dispatch(requestActions.notify(
{
text : `${displayName} joined the room`
}));
}
for (const consumer of peer.consumers)
{
this._handleConsumer(consumer);
}
peer.on('close', (originator) =>
{
logger.debug(
'peer "close" event [name:"%s", originator:%s]',
peer.name, originator);
this._dispatch(stateActions.removePeer(peer.name));
if (this._room.joined)
{
this._dispatch(requestActions.notify(
{
text : `${peer.appData.displayName} left the room`
}));
}
});
peer.on('newconsumer', (consumer) =>
{
logger.debug(
'peer "newconsumer" event [name:"%s", id:%s, consumer:%o]',
peer.name, consumer.id, consumer);
this._handleConsumer(consumer);
});
}
_handleConsumer(consumer)
{
const codec = consumer.rtpParameters.codecs[0];
this._dispatch(stateActions.addConsumer(
{
id : consumer.id,
peerName : consumer.peer.name,
source : consumer.appData.source,
supported : consumer.supported,
locallyPaused : consumer.locallyPaused,
remotelyPaused : consumer.remotelyPaused,
track : null,
codec : codec ? codec.name : null
},
consumer.peer.name)
);
consumer.on('close', (originator) =>
{
logger.debug(
'consumer "close" event [id:%s, originator:%s, consumer:%o]',
consumer.id, originator, consumer);
this._dispatch(stateActions.removeConsumer(
consumer.id, consumer.peer.name));
});
consumer.on('handled', (originator) =>
{
logger.debug(
'consumer "handled" event [id:%s, originator:%s, consumer:%o]',
consumer.id, originator, consumer);
if (consumer.kind === 'audio')
{
const stream = new MediaStream;
stream.addTrack(consumer.track);
if (!stream.getAudioTracks()[0])
throw new Error('consumer.on("handled" | given stream has no audio track');
consumer.hark = hark(stream, { play: false });
// eslint-disable-next-line no-unused-vars
consumer.hark.on('volume_change', (dBs, threshold) =>
{
// The exact formula to convert from dBs (-100..0) to linear (0..1) is:
// Math.pow(10, dBs / 20)
// However it does not produce a visually useful output, so let exagerate
// it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to
// minimize component renderings.
let volume = Math.round(Math.pow(10, dBs / 85) * 10);
if (volume === 1)
volume = 0;
if (volume !== consumer.volume)
{
consumer.volume = volume;
this._dispatch(stateActions.setConsumerVolume(consumer.id, volume));
}
});
}
});
consumer.on('pause', (originator) =>
{
logger.debug(
'consumer "pause" event [id:%s, originator:%s, consumer:%o]',
consumer.id, originator, consumer);
this._dispatch(stateActions.setConsumerPaused(consumer.id, originator));
});
consumer.on('resume', (originator) =>
{
logger.debug(
'consumer "resume" event [id:%s, originator:%s, consumer:%o]',
consumer.id, originator, consumer);
this._dispatch(stateActions.setConsumerResumed(consumer.id, originator));
});
consumer.on('effectiveprofilechange', (profile) =>
{
logger.debug(
'consumer "effectiveprofilechange" event [id:%s, consumer:%o, profile:%s]',
consumer.id, consumer, profile);
this._dispatch(stateActions.setConsumerEffectiveProfile(consumer.id, profile));
});
// Receive the consumer (if we can).
if (consumer.supported)
{
// Pause it if video and we are in audio-only mode.
if (consumer.kind === 'video' && this._getState().me.audioOnly)
consumer.pause('audio-only-mode');
consumer.receive(this._recvTransport)
.then((track) =>
{
this._dispatch(stateActions.setConsumerTrack(consumer.id, track));
})
.catch((error) =>
{
logger.error(
'unexpected error while receiving a new Consumer:%o', error);
});
}
}
}