This commit is contained in:
Iñaki Baz Castillo
2017-11-02 16:38:52 +01:00
parent 625f20f547
commit 52acf81eff
100 changed files with 26907 additions and 10234 deletions
-934
View File
@@ -1,934 +0,0 @@
'use strict';
import events from 'events';
import browser from 'bowser';
import sdpTransform from 'sdp-transform';
import Logger from './Logger';
import protooClient from 'protoo-client';
import * as urlFactory from './urlFactory';
import * as utils from './utils';
const logger = new Logger('Client');
const DO_GETUSERMEDIA = true;
const ENABLE_SIMULCAST = false;
const VIDEO_CONSTRAINS =
{
qvga : { width: { ideal: 320 }, height: { ideal: 240 }},
vga : { width: { ideal: 640 }, height: { ideal: 480 }},
hd : { width: { ideal: 1280 }, height: { ideal: 720 }}
};
export default class Client extends events.EventEmitter
{
constructor(peerId, roomId)
{
logger.debug('constructor() [peerId:"%s", roomId:"%s"]', peerId, roomId);
super();
this.setMaxListeners(Infinity);
// TODO: TMP
global.CLIENT = this;
let url = urlFactory.getProtooUrl(peerId, roomId);
let transport = new protooClient.WebSocketTransport(url);
// protoo-client Peer instance.
this._protooPeer = new protooClient.Peer(transport);
// RTCPeerConnection instance.
this._peerconnection = null;
// Webcam map indexed by deviceId.
this._webcams = new Map();
// Local Webcam device.
this._webcam = null;
// Local MediaStream instance.
this._localStream = null;
// Closed flag.
this._closed = false;
// Local video resolution.
this._localVideoResolution = 'vga';
this._protooPeer.on('open', () =>
{
logger.debug('protoo Peer "open" event');
});
this._protooPeer.on('disconnected', () =>
{
logger.warn('protoo Peer "disconnected" event');
// Close RTCPeerConnection.
try
{
this._peerconnection.close();
}
catch (error) {}
// Close local MediaStream.
if (this._localStream)
utils.closeMediaStream(this._localStream);
this.emit('disconnected');
});
this._protooPeer.on('close', () =>
{
if (this._closed)
return;
logger.warn('protoo Peer "close" event');
this.close();
});
this._protooPeer.on('request', this._handleRequest.bind(this));
}
close()
{
if (this._closed)
return;
this._closed = true;
logger.debug('close()');
// Close protoo Peer.
this._protooPeer.close();
// Close RTCPeerConnection.
try
{
this._peerconnection.close();
}
catch (error) {}
// Close local MediaStream.
if (this._localStream)
utils.closeMediaStream(this._localStream);
// Emit 'close' event.
this.emit('close');
}
removeVideo(dontNegotiate)
{
logger.debug('removeVideo()');
let stream = this._localStream;
let videoTrack = stream.getVideoTracks()[0];
if (!videoTrack)
{
logger.warn('removeVideo() | no video track');
return Promise.reject(new Error('no video track'));
}
videoTrack.stop();
stream.removeTrack(videoTrack);
// New API.
if (this._peerconnection.removeTrack)
{
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === videoTrack)
break;
}
this._peerconnection.removeTrack(sender);
}
// Old API.
else
{
this._peerconnection.addStream(stream);
}
if (!dontNegotiate)
{
this.emit('localstream', stream, null);
return this._requestRenegotiation();
}
}
addVideo()
{
logger.debug('addVideo()');
let stream = this._localStream;
let videoTrack;
let videoResolution = this._localVideoResolution; // Keep previous resolution.
if (stream)
videoTrack = stream.getVideoTracks()[0];
if (videoTrack)
{
logger.warn('addVideo() | there is already a video track');
return Promise.reject(new Error('there is already a video track'));
}
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[videoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
if (stream)
{
stream.addTrack(newVideoTrack);
// New API.
if (this._peerconnection.addTrack)
{
this._peerconnection.addTrack(newVideoTrack, stream);
}
// Old API.
else
{
this._peerconnection.addStream(stream);
}
}
else
{
this._localStream = newStream;
// New API.
if (this._peerconnection.addTrack)
{
this._peerconnection.addTrack(newVideoTrack, stream);
}
// Old API.
else
{
this._peerconnection.addStream(stream);
}
}
this.emit('localstream', this._localStream, videoResolution);
})
.then(() =>
{
return this._requestRenegotiation();
})
.catch((error) =>
{
logger.error('addVideo() failed: %o', error);
throw error;
});
}
changeWebcam()
{
logger.debug('changeWebcam()');
return Promise.resolve()
.then(() =>
{
return this._updateWebcams();
})
.then(() =>
{
let array = Array.from(this._webcams.keys());
let len = array.length;
let deviceId = this._webcam ? this._webcam.deviceId : undefined;
let idx = array.indexOf(deviceId);
if (idx < len - 1)
idx++;
else
idx = 0;
this._webcam = this._webcams.get(array[idx]);
this._emitWebcamType();
if (len < 2)
return;
logger.debug(
'changeWebcam() | new selected webcam [deviceId:"%s"]',
this._webcam.deviceId);
// Reset video resolution to VGA.
this._localVideoResolution = 'vga';
// For Chrome (old WenRTC API).
// Replace the track (so new SSRC) and renegotiate.
if (!this._peerconnection.removeTrack)
{
this.removeVideo(true);
return this.addVideo();
}
// For Firefox (modern WebRTC API).
// Avoid renegotiation.
else
{
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[this._localVideoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
let stream = this._localStream;
let oldVideoTrack = stream.getVideoTracks()[0];
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === oldVideoTrack)
break;
}
sender.replaceTrack(newVideoTrack);
stream.removeTrack(oldVideoTrack);
oldVideoTrack.stop();
stream.addTrack(newVideoTrack);
this.emit('localstream', stream, this._localVideoResolution);
});
}
})
.catch((error) =>
{
logger.error('changeWebcam() failed: %o', error);
});
}
changeVideoResolution()
{
logger.debug('changeVideoResolution()');
let newVideoResolution;
switch (this._localVideoResolution)
{
case 'qvga':
newVideoResolution = 'vga';
break;
case 'vga':
newVideoResolution = 'hd';
break;
case 'hd':
newVideoResolution = 'qvga';
break;
default:
throw new Error(`unknown resolution "${this._localVideoResolution}"`);
}
this._localVideoResolution = newVideoResolution;
// For Chrome (old WenRTC API).
// Replace the track (so new SSRC) and renegotiate.
if (!this._peerconnection.removeTrack)
{
this.removeVideo(true);
return this.addVideo();
}
// For Firefox (modern WebRTC API).
// Avoid renegotiation.
else
{
return this._getLocalStream(
{
video : VIDEO_CONSTRAINS[this._localVideoResolution]
})
.then((newStream) =>
{
let newVideoTrack = newStream.getVideoTracks()[0];
let stream = this._localStream;
let oldVideoTrack = stream.getVideoTracks()[0];
let sender;
for (sender of this._peerconnection.getSenders())
{
if (sender.track === oldVideoTrack)
break;
}
sender.replaceTrack(newVideoTrack);
stream.removeTrack(oldVideoTrack);
oldVideoTrack.stop();
stream.addTrack(newVideoTrack);
this.emit('localstream', stream, newVideoResolution);
})
.catch((error) =>
{
logger.error('changeVideoResolution() failed: %o', error);
});
}
}
getStats()
{
return this._peerconnection.getStats()
.catch((error) =>
{
logger.error('pc.getStats() failed: %o', error);
throw error;
});
}
disableRemoteVideo(msid)
{
return this._protooPeer.send('disableremotevideo', { msid, disable: true })
.catch((error) =>
{
logger.warn('disableRemoteVideo() failed: %o', error);
});
}
enableRemoteVideo(msid)
{
return this._protooPeer.send('disableremotevideo', { msid, disable: false })
.catch((error) =>
{
logger.warn('enableRemoteVideo() failed: %o', error);
});
}
_handleRequest(request, accept, reject)
{
logger.debug('_handleRequest() [method:%s, data:%o]', request.method, request.data);
switch(request.method)
{
case 'joinme':
{
let videoResolution = this._localVideoResolution;
Promise.resolve()
.then(() =>
{
return this._updateWebcams();
})
.then(() =>
{
if (DO_GETUSERMEDIA)
{
return this._getLocalStream(
{
audio : true,
video : VIDEO_CONSTRAINS[videoResolution]
})
.then((stream) =>
{
logger.debug('got local stream [resolution:%s]', videoResolution);
// Close local MediaStream if any.
if (this._localStream)
utils.closeMediaStream(this._localStream);
this._localStream = stream;
// Emit 'localstream' event.
this.emit('localstream', stream, videoResolution);
});
}
})
.then(() =>
{
return this._createPeerConnection();
})
.then(() =>
{
return this._peerconnection.createOffer(
{
offerToReceiveAudio : 1,
offerToReceiveVideo : 1
});
})
.then((offer) =>
{
let capabilities = offer.sdp;
let parsedSdp = sdpTransform.parse(capabilities);
logger.debug('capabilities [parsed:%O, sdp:%s]', parsedSdp, capabilities);
// Accept the protoo request.
accept(
{
capabilities : capabilities,
usePlanB : utils.isPlanB()
});
})
.then(() =>
{
logger.debug('"joinme" request accepted');
// Emit 'join' event.
this.emit('join');
})
.catch((error) =>
{
logger.error('"joinme" request failed: %o', error);
reject(500, error.message);
throw error;
});
break;
}
case 'peers':
{
this.emit('peers', request.data.peers);
accept();
break;
}
case 'addpeer':
{
this.emit('addpeer', request.data.peer);
accept();
break;
}
case 'updatepeer':
{
this.emit('updatepeer', request.data.peer);
accept();
break;
}
case 'removepeer':
{
this.emit('removepeer', request.data.peer);
accept();
break;
}
case 'offer':
{
let offer = new RTCSessionDescription(request.data.offer);
let parsedSdp = sdpTransform.parse(offer.sdp);
logger.debug('received offer [parsed:%O, sdp:%s]', parsedSdp, offer.sdp);
Promise.resolve()
.then(() =>
{
return this._peerconnection.setRemoteDescription(offer);
})
.then(() =>
{
return this._peerconnection.createAnswer();
})
// Play with simulcast.
.then((answer) =>
{
if (!ENABLE_SIMULCAST)
return answer;
// Chrome Plan B simulcast.
if (utils.isPlanB())
{
// Just for the initial offer.
// NOTE: Otherwise Chrome crashes.
// TODO: This prevents simulcast to be applied to new tracks.
if (this._peerconnection.localDescription && this._peerconnection.localDescription.sdp)
return answer;
// TODO: Should be done just for VP8.
let parsedSdp = sdpTransform.parse(answer.sdp);
let videoMedia;
for (let m of parsedSdp.media)
{
if (m.type === 'video')
{
videoMedia = m;
break;
}
}
if (!videoMedia || !videoMedia.ssrcs)
return answer;
logger.debug('setting video simulcast (PlanB)');
let ssrc1;
let ssrc2;
let ssrc3;
let cname;
let msid;
for (let ssrcObj of videoMedia.ssrcs)
{
// Chrome uses:
// a=ssrc:xxxx msid:yyyy zzzz
// a=ssrc:xxxx mslabel:yyyy
// a=ssrc:xxxx label:zzzz
// Where yyyy is the MediaStream.id and zzzz the MediaStreamTrack.id.
switch (ssrcObj.attribute)
{
case 'cname':
ssrc1 = ssrcObj.id;
cname = ssrcObj.value;
break;
case 'msid':
msid = ssrcObj.value;
break;
}
}
ssrc2 = ssrc1 + 1;
ssrc3 = ssrc1 + 2;
videoMedia.ssrcGroups =
[
{
semantics : 'SIM',
ssrcs : `${ssrc1} ${ssrc2} ${ssrc3}`
}
];
videoMedia.ssrcs =
[
{
id : ssrc1,
attribute : 'cname',
value : cname,
},
{
id : ssrc1,
attribute : 'msid',
value : msid,
},
{
id : ssrc2,
attribute : 'cname',
value : cname,
},
{
id : ssrc2,
attribute : 'msid',
value : msid,
},
{
id : ssrc3,
attribute : 'cname',
value : cname,
},
{
id : ssrc3,
attribute : 'msid',
value : msid,
}
];
let modifiedAnswer =
{
type : 'answer',
sdp : sdpTransform.write(parsedSdp)
};
return modifiedAnswer;
}
// Firefox way.
else
{
let parsedSdp = sdpTransform.parse(answer.sdp);
let videoMedia;
logger.debug('created answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
for (let m of parsedSdp.media)
{
if (m.type === 'video' && m.direction === 'sendonly')
{
videoMedia = m;
break;
}
}
if (!videoMedia)
return answer;
logger.debug('setting video simulcast (Unified-Plan)');
videoMedia.simulcast_03 =
{
value : 'send rid=1,2'
};
videoMedia.rids =
[
{ id: '1', direction: 'send' },
{ id: '2', direction: 'send' }
];
let modifiedAnswer =
{
type : 'answer',
sdp : sdpTransform.write(parsedSdp)
};
return modifiedAnswer;
}
})
.then((answer) =>
{
return this._peerconnection.setLocalDescription(answer);
})
.then(() =>
{
let answer = this._peerconnection.localDescription;
let parsedSdp = sdpTransform.parse(answer.sdp);
logger.debug('sent answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
accept(
{
answer :
{
type : answer.type,
sdp : answer.sdp
}
});
})
.catch((error) =>
{
logger.error('"offer" request failed: %o', error);
reject(500, error.message);
throw error;
})
.then(() =>
{
// If Firefox trigger 'forcestreamsupdate' event due to bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
if (browser.firefox || browser.gecko)
{
// Not sure, but it thinks that the timeout does the trick.
setTimeout(() => this.emit('forcestreamsupdate'), 500);
}
});
break;
}
case 'activespeaker':
{
let data = request.data;
this.emit('activespeaker', data.peer, data.level);
accept();
break;
}
default:
{
logger.error('unknown method');
reject(404, 'unknown method');
}
}
}
_updateWebcams()
{
logger.debug('_updateWebcams()');
// Reset the list.
this._webcams = new Map();
return Promise.resolve()
.then(() =>
{
return navigator.mediaDevices.enumerateDevices();
})
.then((devices) =>
{
for (let device of devices)
{
if (device.kind !== 'videoinput')
continue;
this._webcams.set(device.deviceId, device);
}
})
.then(() =>
{
let array = Array.from(this._webcams.values());
let len = array.length;
let currentWebcamId = this._webcam ? this._webcam.deviceId : undefined;
logger.debug('_updateWebcams() [webcams:%o]', array);
if (len === 0)
this._webcam = null;
else if (!this._webcams.has(currentWebcamId))
this._webcam = array[0];
this.emit('numwebcams', len);
this._emitWebcamType();
});
}
_getLocalStream(constraints)
{
logger.debug('_getLocalStream() [constraints:%o, webcam:%o]',
constraints, this._webcam);
if (this._webcam)
constraints.video.deviceId = { exact: this._webcam.deviceId };
return navigator.mediaDevices.getUserMedia(constraints);
}
_createPeerConnection()
{
logger.debug('_createPeerConnection()');
this._peerconnection = new RTCPeerConnection({ iceServers: [] });
// TODO: TMP
global.PC = this._peerconnection;
if (this._localStream)
this._peerconnection.addStream(this._localStream);
this._peerconnection.addEventListener('iceconnectionstatechange', () =>
{
let state = this._peerconnection.iceConnectionState;
if (state === 'failed')
logger.warn('peerconnection "iceconnectionstatechange" event [state:failed]');
else
logger.debug('peerconnection "iceconnectionstatechange" event [state:%s]', state);
this.emit('connectionstate', state);
});
this._peerconnection.addEventListener('addstream', (event) =>
{
let stream = event.stream;
logger.debug('peerconnection "addstream" event [stream:%o]', stream);
this.emit('addstream', stream);
// NOTE: For testing.
let interval = setInterval(() =>
{
if (!stream.active)
{
logger.warn('stream inactive [stream:%o]', stream);
clearInterval(interval);
}
}, 2000);
stream.addEventListener('addtrack', (event) =>
{
let track = event.track;
logger.debug('stream "addtrack" event [track:%o]', track);
this.emit('addtrack', track);
// Firefox does not implement 'stream.onremovetrack' so let's use 'track.ended'.
// But... track "ended" is neither fired.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
track.addEventListener('ended', () =>
{
logger.debug('track "ended" event [track:%o]', track);
this.emit('removetrack', track);
});
});
// NOTE: Not implemented in Firefox.
stream.addEventListener('removetrack', (event) =>
{
let track = event.track;
logger.debug('stream "removetrack" event [track:%o]', track);
this.emit('removetrack', track);
});
});
this._peerconnection.addEventListener('removestream', (event) =>
{
let stream = event.stream;
logger.debug('peerconnection "removestream" event [stream:%o]', stream);
this.emit('removestream', stream);
});
}
_requestRenegotiation()
{
logger.debug('_requestRenegotiation()');
return this._protooPeer.send('reofferme');
}
_restartIce()
{
logger.debug('_restartIce()');
return this._protooPeer.send('restartice')
.then(() =>
{
logger.debug('_restartIce() succeded');
})
.catch((error) =>
{
logger.error('_restartIce() failed: %o', error);
throw error;
});
}
_emitWebcamType()
{
let webcam = this._webcam;
if (!webcam)
return;
if (/(back|rear)/i.test(webcam.label))
{
logger.debug('_emitWebcamType() | it seems to be a back camera');
this.emit('webcamtype', 'back');
}
else
{
logger.debug('_emitWebcamType() | it seems to be a front camera');
this.emit('webcamtype', 'front');
}
}
}
+7 -7
View File
@@ -1,5 +1,3 @@
'use strict';
import debug from 'debug';
const APP_NAME = 'mediasoup-demo';
@@ -10,20 +8,22 @@ export default class Logger
{
if (prefix)
{
this._debug = debug(APP_NAME + ':' + prefix);
this._warn = debug(APP_NAME + ':WARN:' + prefix);
this._error = debug(APP_NAME + ':ERROR:' + prefix);
this._debug = debug(`${APP_NAME}:${prefix}`);
this._warn = debug(`${APP_NAME}:WARN:${prefix}`);
this._error = debug(`${APP_NAME}:ERROR:${prefix}`);
}
else
{
this._debug = debug(APP_NAME);
this._warn = debug(APP_NAME + ':WARN');
this._error = debug(APP_NAME + ':ERROR');
this._warn = debug(`${APP_NAME}:WARN`);
this._error = debug(`${APP_NAME}:ERROR`);
}
/* eslint-disable no-console */
this._debug.log = console.info.bind(console);
this._warn.log = console.warn.bind(console);
this._error.log = console.error.bind(console);
/* eslint-enable no-console */
}
get debug()
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import Logger from '../Logger';
import muiTheme from './muiTheme';
import Notifier from './Notifier';
import Room from './Room';
const logger = new Logger('App'); // eslint-disable-line no-unused-vars
export default class App extends React.Component
{
constructor()
{
super();
this.state = {};
}
render()
{
let props = this.props;
return (
<MuiThemeProvider muiTheme={muiTheme}>
<div data-component='App'>
<Notifier ref='Notifier'/>
<Room
peerId={props.peerId}
roomId={props.roomId}
onNotify={this.handleNotify.bind(this)}
onHideNotification={this.handleHideNotification.bind(this)}
/>
</div>
</MuiThemeProvider>
);
}
handleNotify(data)
{
this.refs.Notifier.notify(data);
}
handleHideNotification(uid)
{
this.refs.Notifier.hideNotification(uid);
}
}
App.propTypes =
{
peerId : PropTypes.string.isRequired,
roomId : PropTypes.string.isRequired
};
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import { RIEInput } from 'riek';
export default class EditableInput extends React.Component
{
render()
{
const {
value,
propName,
className,
classLoading,
classInvalid,
editProps,
onChange
} = this.props;
return (
<RIEInput
value={value}
propName={propName}
className={className}
classLoading={classLoading}
classInvalid={classInvalid}
shouldBlockWhileLoading
editProps={editProps}
change={(data) => onChange(data)}
/>
);
}
shouldComponentUpdate(nextProps)
{
if (nextProps.value === this.props.value)
return false;
return true;
}
}
EditableInput.propTypes =
{
value : PropTypes.string,
propName : PropTypes.string.isRequired,
className : PropTypes.string,
classLoading : PropTypes.string,
classInvalid : PropTypes.string,
editProps : PropTypes.any,
onChange : PropTypes.func.isRequired
};
-156
View File
@@ -1,156 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from 'material-ui/IconButton/IconButton';
import MicOffIcon from 'material-ui/svg-icons/av/mic-off';
import VideoCamOffIcon from 'material-ui/svg-icons/av/videocam-off';
import ChangeVideoCamIcon from 'material-ui/svg-icons/av/repeat';
import classnames from 'classnames';
import Video from './Video';
import Logger from '../Logger';
const logger = new Logger('LocalVideo'); // eslint-disable-line no-unused-vars
export default class LocalVideo extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
micMuted : false,
webcam : props.stream && !!props.stream.getVideoTracks()[0],
togglingWebcam : false
};
}
render()
{
let props = this.props;
let state = this.state;
return (
<div
data-component='LocalVideo'
className={classnames(`state-${props.connectionState}`, {
'active-speaker' : props.isActiveSpeaker
})}
>
{props.stream ?
<Video
stream={props.stream}
resolution={props.resolution}
muted
mirror={props.webcamType === 'front'}
onResolutionChange={this.handleResolutionChange.bind(this)}
/>
:null}
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteMic.bind(this)}
>
<MicOffIcon
color={!state.micMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
disabled={state.togglingWebcam}
onClick={this.handleClickWebcam.bind(this)}
>
<VideoCamOffIcon
color={state.webcam ? '#fff' : '#ff8a00'}
/>
</IconButton>
{props.multipleWebcams ?
<IconButton
className='control'
disabled={!state.webcam || state.togglingWebcam}
onClick={this.handleClickChangeWebcam.bind(this)}
>
<ChangeVideoCamIcon
color='#fff'
/>
</IconButton>
:null}
</div>
<div className='info'>
<div className='peer-id'>{props.peerId}</div>
</div>
</div>
);
}
componentWillReceiveProps(nextProps)
{
this.setState({ webcam: nextProps.stream && !!nextProps.stream.getVideoTracks()[0] });
}
handleClickMuteMic()
{
logger.debug('handleClickMuteMic()');
let value = !this.state.micMuted;
this.props.onMicMute(value)
.then(() =>
{
this.setState({ micMuted: value });
});
}
handleClickWebcam()
{
logger.debug('handleClickWebcam()');
let value = !this.state.webcam;
this.setState({ togglingWebcam: true });
this.props.onWebcamToggle(value)
.then(() =>
{
this.setState({ webcam: value, togglingWebcam: false });
})
.catch(() =>
{
this.setState({ togglingWebcam: false });
});
}
handleClickChangeWebcam()
{
logger.debug('handleClickChangeWebcam()');
this.props.onWebcamChange();
}
handleResolutionChange()
{
logger.debug('handleResolutionChange()');
this.props.onResolutionChange();
}
}
LocalVideo.propTypes =
{
peerId : PropTypes.string.isRequired,
stream : PropTypes.object,
resolution : PropTypes.string,
multipleWebcams : PropTypes.bool.isRequired,
webcamType : PropTypes.string,
connectionState : PropTypes.string,
isActiveSpeaker : PropTypes.bool.isRequired,
onMicMute : PropTypes.func.isRequired,
onWebcamToggle : PropTypes.func.isRequired,
onWebcamChange : PropTypes.func.isRequired,
onResolutionChange : PropTypes.func.isRequired
};
+222
View File
@@ -0,0 +1,222 @@
import React from 'react';
import { connect } from 'react-redux';
import ReactTooltip from 'react-tooltip';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { getDeviceInfo } from 'mediasoup-client';
import * as appPropTypes from './appPropTypes';
import * as requestActions from '../redux/requestActions';
import PeerView from './PeerView';
class Me extends React.Component
{
constructor(props)
{
super(props);
this._mounted = false;
this._rootNode = null;
this._tooltip = true;
// TODO: Issue when using react-tooltip in Edge:
// https://github.com/wwayne/react-tooltip/issues/328
if (getDeviceInfo().flag === 'msedge')
this._tooltip = false;
}
render()
{
const {
connected,
me,
micProducer,
webcamProducer,
onChangeDisplayName,
onMuteMic,
onUnmuteMic,
onEnableWebcam,
onDisableWebcam,
onChangeWebcam
} = this.props;
let micState;
if (!me.canSendMic)
micState = 'unsupported';
else if (!micProducer)
micState = 'unsupported';
else if (!micProducer.locallyPaused && !micProducer.remotelyPaused)
micState = 'on';
else
micState = 'off';
let webcamState;
if (!me.canSendWebcam)
webcamState = 'unsupported';
else if (webcamProducer)
webcamState = 'on';
else
webcamState = 'off';
let changeWebcamState;
if (Boolean(webcamProducer) && me.canChangeWebcam)
changeWebcamState = 'on';
else
changeWebcamState = 'unsupported';
const videoVisible = (
Boolean(webcamProducer) &&
!webcamProducer.locallyPaused &&
!webcamProducer.remotelyPaused
);
let tip;
if (!me.displayNameSet)
tip = 'Click on your name to change it';
return (
<div
data-component='Me'
ref={(node) => (this._rootNode = node)}
data-tip={tip}
data-tip-disable={!tip}
data-type='dark'
>
{connected ?
<div className='controls'>
<div
className={classnames('button', 'mic', micState)}
onClick={() =>
{
micState === 'on' ? onMuteMic() : onUnmuteMic();
}}
/>
<div
className={classnames('button', 'webcam', webcamState, {
disabled : me.webcamInProgress
})}
onClick={() =>
{
webcamState === 'on' ? onDisableWebcam() : onEnableWebcam();
}}
/>
<div
className={classnames('button', 'change-webcam', changeWebcamState, {
disabled : me.webcamInProgress
})}
onClick={() => onChangeWebcam()}
/>
</div>
:null
}
<PeerView
isMe
peer={me}
audioTrack={micProducer ? micProducer.track : null}
videoTrack={webcamProducer ? webcamProducer.track : null}
videoVisible={videoVisible}
audioCodec={micProducer ? micProducer.codec : null}
videoCodec={webcamProducer ? webcamProducer.codec : null}
onChangeDisplayName={(displayName) => onChangeDisplayName(displayName)}
/>
{this._tooltip ?
<ReactTooltip
effect='solid'
delayShow={100}
delayHide={100}
/>
:null
}
</div>
);
}
componentDidMount()
{
this._mounted = true;
if (this._tooltip)
{
setTimeout(() =>
{
if (!this._mounted || this.props.me.displayNameSet)
return;
ReactTooltip.show(this._rootNode);
}, 4000);
}
}
componentWillUnmount()
{
this._mounted = false;
}
componentWillReceiveProps(nextProps)
{
if (this._tooltip)
{
if (nextProps.me.displayNameSet)
ReactTooltip.hide(this._rootNode);
}
}
}
Me.propTypes =
{
connected : PropTypes.bool.isRequired,
me : appPropTypes.Me.isRequired,
micProducer : appPropTypes.Producer,
webcamProducer : appPropTypes.Producer,
onChangeDisplayName : PropTypes.func.isRequired,
onMuteMic : PropTypes.func.isRequired,
onUnmuteMic : PropTypes.func.isRequired,
onEnableWebcam : PropTypes.func.isRequired,
onDisableWebcam : PropTypes.func.isRequired,
onChangeWebcam : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
const producersArray = Object.values(state.producers);
const micProducer =
producersArray.find((producer) => producer.source === 'mic');
const webcamProducer =
producersArray.find((producer) => producer.source === 'webcam');
return {
connected : state.room.state === 'connected',
me : state.me,
micProducer : micProducer,
webcamProducer : webcamProducer
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
onChangeDisplayName : (displayName) =>
{
dispatch(requestActions.changeDisplayName(displayName));
},
onMuteMic : () => dispatch(requestActions.muteMic()),
onUnmuteMic : () => dispatch(requestActions.unmuteMic()),
onEnableWebcam : () => dispatch(requestActions.enableWebcam()),
onDisableWebcam : () => dispatch(requestActions.disableWebcam()),
onChangeWebcam : () => dispatch(requestActions.changeWebcam())
};
};
const MeContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Me);
export default MeContainer;
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { connect } from 'react-redux';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import * as appPropTypes from './appPropTypes';
import * as stateActions from '../redux/stateActions';
import { Appear } from './transitions';
const Notifications = ({ notifications, onClick }) =>
{
return (
<div data-component='Notifications'>
{
notifications.map((notification) =>
{
return (
<Appear key={notification.id} duration={250}>
<div
className={classnames('notification', notification.type)}
onClick={() => onClick(notification.id)}
>
<div className='icon' />
<p className='text'>{notification.text}</p>
</div>
</Appear>
);
})
}
</div>
);
};
Notifications.propTypes =
{
notifications : PropTypes.arrayOf(appPropTypes.Notification).isRequired,
onClick : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
const { notifications } = state;
return { notifications };
};
const mapDispatchToProps = (dispatch) =>
{
return {
onClick : (notificationId) =>
{
dispatch(stateActions.removeNotification(notificationId));
}
};
};
const NotificationsContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Notifications);
export default NotificationsContainer;
-151
View File
@@ -1,151 +0,0 @@
'use strict';
import React from 'react';
import NotificationSystem from 'react-notification-system';
const STYLE =
{
NotificationItem :
{
DefaultStyle :
{
padding : '6px 10px',
backgroundColor : 'rgba(255,255,255, 0.9)',
fontFamily : 'Roboto',
fontWeight : 400,
fontSize : '1rem',
cursor : 'default',
WebkitUserSelect : 'none',
MozUserSelect : 'none',
userSelect : 'none',
transition : '0.15s ease-in-out'
},
info :
{
color : '#000',
borderTop : '2px solid rgba(255,0,78, 0.75)'
},
success :
{
color : '#000',
borderTop : '4px solid rgba(73,206,62, 0.75)'
},
error :
{
color : '#000',
borderTop : '4px solid #ff0014'
}
},
Title :
{
DefaultStyle :
{
margin : '0 0 8px 0',
fontFamily : 'Roboto',
fontWeight : 500,
fontSize : '1.1rem',
userSelect : 'none',
WebkitUserSelect : 'none',
MozUserSelect : 'none'
},
info :
{
color : 'rgba(255,0,78, 0.85)'
},
success :
{
color : 'rgba(73,206,62, 0.9)'
},
error :
{
color : '#ff0014'
}
},
Dismiss :
{
DefaultStyle :
{
display : 'none'
}
},
Action :
{
DefaultStyle :
{
padding : '8px 24px',
fontSize : '1.2rem',
cursor : 'pointer',
userSelect : 'none',
WebkitUserSelect : 'none',
MozUserSelect : 'none'
},
info :
{
backgroundColor : 'rgba(255,0,78, 1)'
},
success :
{
backgroundColor : 'rgba(73,206,62, 0.75)'
}
}
};
export default class Notifier extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
return (
<NotificationSystem ref='NotificationSystem' style={STYLE} allowHTML={false}/>
);
}
notify(data)
{
let data2;
switch (data.level)
{
case 'info' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 1
}, data);
break;
case 'success' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 1
}, data);
break;
case 'error' :
data2 = Object.assign(
{
position : 'tr',
dismissible : true,
autoDismiss : 3
}, data);
break;
default:
throw new Error(`unknown level "${data.level}"`);
}
this.refs.NotificationSystem.addNotification(data2);
}
hideNotification(uid)
{
this.refs.NotificationSystem.removeNotification(uid);
}
}
+90
View File
@@ -0,0 +1,90 @@
import React from 'react';
import { connect } from 'react-redux';
import * as appPropTypes from './appPropTypes';
import PeerView from './PeerView';
const Peer = (props) =>
{
const {
peer,
micConsumer,
webcamConsumer
} = props;
const micEnabled = (
Boolean(micConsumer) &&
!micConsumer.locallyPaused &&
!micConsumer.remotelyPaused
);
const videoVisible = (
Boolean(webcamConsumer) &&
!webcamConsumer.locallyPaused &&
!webcamConsumer.remotelyPaused
);
let videoProfile;
if (webcamConsumer)
videoProfile = webcamConsumer.profile;
return (
<div data-component='Peer'>
<div className='indicators'>
{!micEnabled ?
<div className='icon mic-off' />
:null
}
{!videoVisible ?
<div className='icon webcam-off' />
:null
}
</div>
{videoVisible && !webcamConsumer.supported ?
<div className='incompatible-video'>
<p>incompatible video</p>
</div>
:null
}
<PeerView
peer={peer}
audioTrack={micConsumer ? micConsumer.track : null}
videoTrack={webcamConsumer ? webcamConsumer.track : null}
videoVisible={videoVisible}
videoProfile={videoProfile}
audioCodec={micConsumer ? micConsumer.codec : null}
videoCodec={webcamConsumer ? webcamConsumer.codec : null}
/>
</div>
);
};
Peer.propTypes =
{
peer : appPropTypes.Peer.isRequired,
micConsumer : appPropTypes.Consumer,
webcamConsumer : appPropTypes.Consumer
};
const mapStateToProps = (state, { name }) =>
{
const peer = state.peers[name];
const consumersArray = peer.consumers
.map((consumerId) => state.consumers[consumerId]);
const micConsumer =
consumersArray.find((consumer) => consumer.source === 'mic');
const webcamConsumer =
consumersArray.find((consumer) => consumer.source === 'webcam');
return {
peer,
micConsumer,
webcamConsumer
};
};
const PeerContainer = connect(mapStateToProps)(Peer);
export default PeerContainer;
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Spinner from 'react-spinner';
import hark from 'hark';
import * as appPropTypes from './appPropTypes';
import EditableInput from './EditableInput';
export default class PeerView extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
volume : 0, // Integer from 0 to 10.,
videoWidth : null,
videoHeight : null
};
// Latest received video track.
// @type {MediaStreamTrack}
this._audioTrack = null;
// Latest received video track.
// @type {MediaStreamTrack}
this._videoTrack = null;
// Hark instance.
// @type {Object}
this._hark = null;
// Periodic timer for showing video resolution.
this._videoResolutionTimer = null;
}
render()
{
const {
isMe,
peer,
videoVisible,
videoProfile,
audioCodec,
videoCodec,
onChangeDisplayName
} = this.props;
const {
volume,
videoWidth,
videoHeight
} = this.state;
return (
<div data-component='PeerView'>
<div className='info'>
<div className={classnames('media', { 'is-me': isMe })}>
<div className='box'>
{audioCodec ?
<p className='codec'>{audioCodec}</p>
:null
}
{videoCodec ?
<p className='codec'>{videoCodec} {videoProfile}</p>
:null
}
{(videoVisible && videoWidth !== null) ?
<p className='resolution'>{videoWidth}x{videoHeight}</p>
:null
}
</div>
</div>
<div className={classnames('peer', { 'is-me': isMe })}>
{isMe ?
<EditableInput
value={peer.displayName}
propName='displayName'
className='display-name editable'
classLoading='loading'
classInvalid='invalid'
shouldBlockWhileLoading
editProps={{
maxLength : 20,
autoCorrect : false,
spellCheck : false
}}
onChange={({ displayName }) => onChangeDisplayName(displayName)}
/>
:
<span className='display-name'>
{peer.displayName}
</span>
}
<div className='row'>
<span
className={classnames('device-icon', peer.device.flag)}
/>
<span className='device-version'>
{peer.device.name} {Math.floor(peer.device.version) || null}
</span>
</div>
</div>
</div>
<video
ref='video'
className={classnames({
hidden : !videoVisible,
'is-me' : isMe,
loading : videoProfile === 'none'
})}
autoPlay
muted={isMe}
/>
<div className='volume-container'>
<div className={classnames('bar', `level${volume}`)} />
</div>
{videoProfile === 'none' ?
<div className='spinner-container'>
<Spinner />
</div>
:null
}
</div>
);
}
componentDidMount()
{
const { audioTrack, videoTrack } = this.props;
this._setTracks(audioTrack, videoTrack);
}
componentWillUnmount()
{
if (this._hark)
this._hark.stop();
clearInterval(this._videoResolutionTimer);
}
componentWillReceiveProps(nextProps)
{
const { audioTrack, videoTrack } = nextProps;
this._setTracks(audioTrack, videoTrack);
}
_setTracks(audioTrack, videoTrack)
{
if (this._audioTrack === audioTrack && this._videoTrack === videoTrack)
return;
this._audioTrack = audioTrack;
this._videoTrack = videoTrack;
if (this._hark)
this._hark.stop();
clearInterval(this._videoResolutionTimer);
this._hideVideoResolution();
const { video } = this.refs;
if (audioTrack || videoTrack)
{
const stream = new MediaStream;
if (audioTrack)
stream.addTrack(audioTrack);
if (videoTrack)
stream.addTrack(videoTrack);
video.srcObject = stream;
if (audioTrack)
this._runHark(stream);
if (videoTrack)
this._showVideoResolution();
}
else
{
video.srcObject = null;
}
}
_runHark(stream)
{
if (!stream.getAudioTracks()[0])
throw new Error('_runHark() | given stream has no audio track');
this._hark = hark(stream, { play: false });
// eslint-disable-next-line no-unused-vars
this._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 !== this.state.volume)
this.setState({ volume: volume });
});
}
_showVideoResolution()
{
this._videoResolutionTimer = setInterval(() =>
{
const { videoWidth, videoHeight } = this.state;
const { video } = this.refs;
// Don't re-render if nothing changed.
if (video.videoWidth === videoWidth && video.videoHeight === videoHeight)
return;
this.setState(
{
videoWidth : video.videoWidth,
videoHeight : video.videoHeight
});
}, 1000);
}
_hideVideoResolution()
{
this.setState({ videoWidth: null, videoHeight: null });
}
}
PeerView.propTypes =
{
isMe : PropTypes.bool,
peer : PropTypes.oneOfType(
[ appPropTypes.Me, appPropTypes.Peer ]).isRequired,
audioTrack : PropTypes.any,
videoTrack : PropTypes.any,
videoVisible : PropTypes.bool.isRequired,
videoProfile : PropTypes.string,
audioCodec : PropTypes.string,
videoCodec : PropTypes.string,
onChangeDisplayName : PropTypes.func
};
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as appPropTypes from './appPropTypes';
import { Appear } from './transitions';
import Peer from './Peer';
const Peers = ({ peers, activeSpeakerName }) =>
{
return (
<div data-component='Peers'>
{
peers.map((peer) =>
{
return (
<Appear key={peer.name} duration={1000}>
<div
className={classnames('peer-container', {
'active-speaker' : peer.name === activeSpeakerName
})}
>
<Peer name={peer.name} />
</div>
</Appear>
);
})
}
</div>
);
};
Peers.propTypes =
{
peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired,
activeSpeakerName : PropTypes.string
};
const mapStateToProps = (state) =>
{
// TODO: This is not OK since it's creating a new array every time, so triggering a
// component rendering.
const peersArray = Object.values(state.peers);
return {
peers : peersArray,
activeSpeakerName : state.room.activeSpeakerName
};
};
const PeersContainer = connect(mapStateToProps)(Peers);
export default PeersContainer;
-138
View File
@@ -1,138 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from 'material-ui/IconButton/IconButton';
import VolumeOffIcon from 'material-ui/svg-icons/av/volume-off';
import VideoOffIcon from 'material-ui/svg-icons/av/videocam-off';
import classnames from 'classnames';
import Video from './Video';
import Logger from '../Logger';
const logger = new Logger('RemoteVideo');
export default class RemoteVideo extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
audioMuted : false
};
let videoTrack = props.stream.getVideoTracks()[0];
if (videoTrack)
{
videoTrack.addEventListener('mute', () =>
{
logger.debug('video track "mute" event');
});
videoTrack.addEventListener('unmute', () =>
{
logger.debug('video track "unmute" event');
});
}
}
render()
{
let props = this.props;
let state = this.state;
let videoTrack = props.stream.getVideoTracks()[0];
let videoEnabled = videoTrack && videoTrack.enabled;
return (
<div
data-component='RemoteVideo'
className={classnames({
fullsize : !!props.fullsize,
'active-speaker' : props.isActiveSpeaker
})}
>
<Video
stream={props.stream}
muted={state.audioMuted}
videoDisabled={!videoEnabled}
/>
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteAudio.bind(this)}
>
<VolumeOffIcon
color={!state.audioMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
{videoTrack ?
<IconButton
className='control'
onClick={this.handleClickDisableVideo.bind(this)}
>
<VideoOffIcon
color={videoEnabled ? '#fff' : '#ff8a00'}
/>
</IconButton>
:null}
</div>
<div className='info'>
<div className='peer-id'>{props.peer.id}</div>
</div>
</div>
);
}
handleClickMuteAudio()
{
logger.debug('handleClickMuteAudio()');
let value = !this.state.audioMuted;
this.setState({ audioMuted: value });
}
handleClickDisableVideo()
{
logger.debug('handleClickDisableVideo()');
let videoTrack = this.props.stream.getVideoTracks()[0];
let videoEnabled = videoTrack && videoTrack.enabled;
let stream = this.props.stream;
let msid = stream.jitsiRemoteId || stream.id;
if (videoEnabled)
{
this.props.onDisableVideo(msid)
.then(() =>
{
videoTrack.enabled = false;
this.forceUpdate();
});
}
else
{
this.props.onEnableVideo(msid)
.then(() =>
{
videoTrack.enabled = true;
this.forceUpdate();
});
}
}
}
RemoteVideo.propTypes =
{
peer : PropTypes.object.isRequired,
stream : PropTypes.object.isRequired,
fullsize : PropTypes.bool,
isActiveSpeaker : PropTypes.bool.isRequired,
onDisableVideo : PropTypes.func.isRequired,
onEnableVideo : PropTypes.func.isRequired
};
+135 -513
View File
@@ -1,531 +1,153 @@
'use strict';
import React from 'react';
import { connect } from 'react-redux';
import ReactTooltip from 'react-tooltip';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ClipboardButton from 'react-clipboard.js';
import browser from 'bowser';
import TransitionAppear from './TransitionAppear';
import LocalVideo from './LocalVideo';
import RemoteVideo from './RemoteVideo';
import Stats from './Stats';
import Logger from '../Logger';
import * as utils from '../utils';
import Client from '../Client';
import * as appPropTypes from './appPropTypes';
import * as requestActions from '../redux/requestActions';
import { Appear } from './transitions';
import Me from './Me';
import Peers from './Peers';
import Notifications from './Notifications';
const logger = new Logger('Room');
const STATS_INTERVAL = 1000;
export default class Room extends React.Component
const Room = (
{
room,
me,
amActiveSpeaker,
onRoomLinkCopy,
onSetAudioMode,
onRestartIce
}) =>
{
constructor(props)
{
super(props);
return (
<Appear duration={300}>
<div data-component='Room'>
<Notifications />
this.state =
{
peers : {},
localStream : null,
localVideoResolution : null, // qvga / vga / hd / fullhd.
multipleWebcams : false,
webcamType : null,
connectionState : null,
remoteStreams : {},
showStats : false,
stats : null,
activeSpeakerId : null
};
<div className='state'>
<div className={classnames('icon', room.state)} />
<p className={classnames('text', room.state)}>{room.state}</p>
</div>
// Mounted flag
this._mounted = false;
// Client instance
this._client = null;
// Timer to retrieve RTC stats.
this._statsTimer = null;
// TODO: TMP
global.ROOM = this;
}
render()
{
let props = this.props;
let state = this.state;
let numPeers = Object.keys(state.remoteStreams).length;
return (
<TransitionAppear duration={2000}>
<div data-component='Room'>
<div className='room-link-wrapper'>
<div className='room-link'>
<ClipboardButton
component='a'
className='link'
button-href={window.location.href}
data-clipboard-text={window.location.href}
onSuccess={this.handleRoomLinkCopied.bind(this)}
onClick={() => {}} // Avoid link action.
>
invite people to this room
</ClipboardButton>
</div>
</div>
<div className='remote-videos'>
{
Object.keys(state.remoteStreams).map((msid) =>
<div className='room-link-wrapper'>
<div className='room-link'>
<ClipboardButton
component='a'
className='link'
button-href={room.url}
button-target='_blank'
data-clipboard-text={room.url}
onSuccess={onRoomLinkCopy}
onClick={(event) =>
{
let stream = state.remoteStreams[msid];
let peer;
for (let peerId of Object.keys(state.peers))
// If this is a 'Open in new window/tab' don't prevent
// click default action.
if (
event.ctrlKey || event.shiftKey || event.metaKey ||
// Middle click (IE > 9 and everyone else).
(event.button && event.button === 1)
)
{
peer = state.peers[peerId];
if (peer.msids.indexOf(msid) !== -1)
break;
return;
}
if (!peer)
return;
return (
<TransitionAppear key={msid} duration={500}>
<RemoteVideo
peer={peer}
stream={stream}
fullsize={numPeers === 1}
isActiveSpeaker={peer.id === state.activeSpeakerId}
onDisableVideo={this.handleDisableRemoteVideo.bind(this)}
onEnableVideo={this.handleEnableRemoteVideo.bind(this)}
/>
</TransitionAppear>
);
})
}
event.preventDefault();
}}
>
invitation link
</ClipboardButton>
</div>
<TransitionAppear duration={500}>
<div className='local-video'>
<LocalVideo
peerId={props.peerId}
stream={state.localStream}
resolution={state.localVideoResolution}
multipleWebcams={state.multipleWebcams}
webcamType={state.webcamType}
connectionState={state.connectionState}
isActiveSpeaker={props.peerId === state.activeSpeakerId}
onMicMute={this.handleLocalMute.bind(this)}
onWebcamToggle={this.handleLocalWebcamToggle.bind(this)}
onWebcamChange={this.handleLocalWebcamChange.bind(this)}
onResolutionChange={this.handleLocalResolutionChange.bind(this)}
/>
{state.showStats ?
<TransitionAppear duration={500}>
<Stats
stats={state.stats || new Map()}
onClose={this.handleStatsClose.bind(this)}
/>
</TransitionAppear>
:
<div
className='show-stats'
onClick={this.handleClickShowStats.bind(this)}
/>
}
</div>
</TransitionAppear>
</div>
</TransitionAppear>
);
}
componentDidMount()
{
// Set flag
this._mounted = true;
// Run the client
this._runClient();
}
componentWillUnmount()
{
let state = this.state;
// Unset flag
this._mounted = false;
// Close client
this._client.removeAllListeners();
this._client.close();
// Close local MediaStream
if (state.localStream)
utils.closeMediaStream(state.localStream);
}
handleRoomLinkCopied()
{
logger.debug('handleRoomLinkCopied()');
this.props.onNotify(
{
level : 'success',
position : 'tr',
title : 'Room URL copied to the clipboard',
message : 'Share it with others to join this room'
});
}
handleLocalMute(value)
{
logger.debug('handleLocalMute() [value:%s]', value);
let micTrack = this.state.localStream.getAudioTracks()[0];
if (!micTrack)
return Promise.reject(new Error('no audio track'));
micTrack.enabled = !value;
return Promise.resolve();
}
handleLocalWebcamToggle(value)
{
logger.debug('handleLocalWebcamToggle() [value:%s]', value);
return Promise.resolve()
.then(() =>
{
if (value)
return this._client.addVideo();
else
return this._client.removeVideo();
})
.then(() =>
{
let localStream = this.state.localStream;
this.setState({ localStream });
});
}
handleLocalWebcamChange()
{
logger.debug('handleLocalWebcamChange()');
this._client.changeWebcam();
}
handleLocalResolutionChange()
{
logger.debug('handleLocalResolutionChange()');
if (!utils.canChangeResolution())
{
logger.warn('changing local resolution not implemented for this browser');
return;
}
this._client.changeVideoResolution();
}
handleStatsClose()
{
logger.debug('handleStatsClose()');
this.setState({ showStats: false });
this._stopStats();
}
handleClickShowStats()
{
logger.debug('handleClickShowStats()');
this.setState({ showStats: true });
this._startStats();
}
handleDisableRemoteVideo(msid)
{
logger.debug('handleDisableRemoteVideo() [msid:"%s"]', msid);
return this._client.disableRemoteVideo(msid);
}
handleEnableRemoteVideo(msid)
{
logger.debug('handleEnableRemoteVideo() [msid:"%s"]', msid);
return this._client.enableRemoteVideo(msid);
}
_runClient()
{
let peerId = this.props.peerId;
let roomId = this.props.roomId;
logger.debug('_runClient() [peerId:"%s", roomId:"%s"]', peerId, roomId);
this._client = new Client(peerId, roomId);
this._client.on('localstream', (stream, resolution) =>
{
this.setState(
{
localStream : stream,
localVideoResolution : resolution
});
});
this._client.on('join', () =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
this.props.onNotify(
{
level : 'success',
title : 'Yes!',
message : 'You are in the room!',
image : '/resources/images/room.svg',
imageWidth : 80,
imageHeight : 80
});
// Start retrieving WebRTC stats (unless mobile or Edge).
if (utils.isDesktop() && !browser.msedge)
{
this.setState({ showStats: true });
setTimeout(() =>
{
this._startStats();
}, STATS_INTERVAL / 2);
}
});
this._client.on('close', (error) =>
{
// Clear remote streams (for reconnections) and more stuff.
this.setState(
{
remoteStreams : {},
activeSpeakerId : null
});
if (error)
{
this.props.onNotify(
{
level : 'error',
title : 'Error',
message : error.message
});
}
// Stop retrieving WebRTC stats.
this._stopStats();
});
this._client.on('disconnected', () =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
this.props.onNotify(
{
level : 'error',
title : 'Warning',
message : 'app disconnected'
});
// Stop retrieving WebRTC stats.
this._stopStats();
});
this._client.on('numwebcams', (num) =>
{
this.setState(
{
multipleWebcams : (num > 1 ? true : false)
});
});
this._client.on('webcamtype', (type) =>
{
this.setState({ webcamType: type });
});
this._client.on('peers', (peers) =>
{
let peersObject = {};
for (let peer of peers)
{
peersObject[peer.id] = peer;
}
this.setState({ peers: peersObject });
});
this._client.on('addpeer', (peer) =>
{
this.props.onNotify(
{
level : 'success',
message : `${peer.id} joined the room`
});
let peers = this.state.peers;
peers[peer.id] = peer;
this.setState({ peers });
});
this._client.on('updatepeer', (peer) =>
{
let peers = this.state.peers;
peers[peer.id] = peer;
this.setState({ peers });
});
this._client.on('removepeer', (peer) =>
{
this.props.onNotify(
{
level : 'info',
message : `${peer.id} left the room`
});
let peers = this.state.peers;
peer = peers[peer.id];
if (!peer)
return;
delete peers[peer.id];
// NOTE: This shouldn't be needed but Safari 11 does not fire pc "removestream"
// nor stream "removetrack" nor track "ended", so we need to cleanup remote
// streams when a peer leaves.
let remoteStreams = this.state.remoteStreams;
for (let msid of peer.msids)
{
delete remoteStreams[msid];
}
this.setState({ peers, remoteStreams });
});
this._client.on('connectionstate', (state) =>
{
this.setState({ connectionState: state });
});
this._client.on('addstream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
let streamId = stream.jitsiRemoteId || stream.id;
remoteStreams[streamId] = stream;
this.setState({ remoteStreams });
});
this._client.on('removestream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
let streamId = stream.jitsiRemoteId || stream.id;
delete remoteStreams[streamId];
this.setState({ remoteStreams });
});
this._client.on('addtrack', () =>
{
let remoteStreams = this.state.remoteStreams;
this.setState({ remoteStreams });
});
this._client.on('removetrack', () =>
{
let remoteStreams = this.state.remoteStreams;
this.setState({ remoteStreams });
});
this._client.on('forcestreamsupdate', () =>
{
// Just firef for Firefox due to bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this.forceUpdate();
});
this._client.on('activespeaker', (peer) =>
{
this.setState(
{
activeSpeakerId : (peer ? peer.id : null)
});
});
}
_startStats()
{
logger.debug('_startStats()');
getStats.call(this);
function getStats()
{
this._client.getStats()
.then((stats) =>
{
if (!this._mounted)
return;
this.setState({ stats });
this._statsTimer = setTimeout(() =>
{
getStats.call(this);
}, STATS_INTERVAL);
})
.catch((error) =>
{
logger.error('getStats() failed: %o', error);
this.setState({ stats: null });
// this._statsTimer = setTimeout(() =>
// {
// getStats.call(this);
// }, STATS_INTERVAL);
});
}
}
_stopStats()
{
logger.debug('_stopStats()');
this.setState({ stats: null });
clearTimeout(this._statsTimer);
}
}
<Peers />
<div
className={classnames('me-container', {
'active-speaker' : amActiveSpeaker
})}
>
<Me />
</div>
<div className='sidebar'>
<div
className={classnames('button', 'audio-only', {
on : me.audioOnly,
disabled : me.audioOnlyInProgress
})}
data-tip='Toggle audio only mode'
data-type='dark'
onClick={() => onSetAudioMode(!me.audioOnly)}
/>
<div
className={classnames('button', 'restart-ice', {
disabled : me.restartIceInProgress
})}
data-tip='Restart ICE'
data-type='dark'
onClick={() => onRestartIce()}
/>
</div>
<ReactTooltip
effect='solid'
delayShow={100}
delayHide={100}
/>
</div>
</Appear>
);
};
Room.propTypes =
{
peerId : PropTypes.string.isRequired,
roomId : PropTypes.string.isRequired,
onNotify : PropTypes.func.isRequired,
onHideNotification : PropTypes.func.isRequired
room : appPropTypes.Room.isRequired,
me : appPropTypes.Me.isRequired,
amActiveSpeaker : PropTypes.bool.isRequired,
onRoomLinkCopy : PropTypes.func.isRequired,
onSetAudioMode : PropTypes.func.isRequired,
onRestartIce : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
return {
room : state.room,
me : state.me,
amActiveSpeaker : state.me.name === state.room.activeSpeakerName
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
onRoomLinkCopy : () =>
{
dispatch(requestActions.notify(
{
text : 'Room link copied to the clipboard'
}));
},
onSetAudioMode : (enable) =>
{
if (enable)
dispatch(requestActions.enableAudioOnly());
else
dispatch(requestActions.disableAudioOnly());
},
onRestartIce : () =>
{
dispatch(requestActions.restartIce());
}
};
};
const RoomContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Room);
export default RoomContainer;
-585
View File
@@ -1,585 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import browser from 'bowser';
import Logger from '../Logger';
const logger = new Logger('Stats'); // eslint-disable-line no-unused-vars
// TODO: TMP
global.BROWSER = browser;
export default class Stats extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
stats :
{
transport : null,
audio : null,
video : null
}
};
}
componentWillMount()
{
let stats = this.props.stats;
this._processStats(stats);
}
componentWillReceiveProps(nextProps)
{
let stats = nextProps.stats;
this._processStats(stats);
}
render()
{
let state = this.state;
return (
<div data-component='Stats'>
<div
className='close'
onClick={this.handleCloseClick.bind(this)}
/>
{
Object.keys(state.stats).map((blockName) =>
{
let block = state.stats[blockName];
if (!block)
return;
let items = Object.keys(block).map((itemName) =>
{
let value = block[itemName];
if (value === undefined)
return;
return (
<div key={itemName} className='item'>
<div className='key'>{itemName}</div>
<div className='value'>{value}</div>
</div>
);
});
if (!items.length)
return null;
return (
<div key={blockName} className='block'>
<h1>{blockName}</h1>
{items}
</div>
);
})
}
</div>
);
}
handleCloseClick()
{
this.props.onClose();
}
_processStats(stats)
{
// global.STATS = stats; // TODO: REMOVE
if (browser.check({ chrome: '58' }, true))
{
this._processStatsChrome58(stats);
}
else if (browser.check({ chrome: '40' }, true))
{
this._processStatsChromeOld(stats);
}
else if (browser.check({ firefox: '40' }, true))
{
this._processStatsFirefox(stats);
}
else if (browser.check({ safari: '11' }, true))
{
this._processStatsSafari11(stats);
}
else
{
logger.warn('_processStats() | unsupported browser [name:"%s", version:%s]',
browser.name, browser.version);
}
}
_processStatsChrome58(stats)
{
let transport = {};
let audio = {};
let video = {};
let selectedCandidatePair = null;
let localCandidates = {};
let remoteCandidates = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'transport':
{
transport['bytes sent'] = group.bytesSent;
transport['bytes received'] = group.bytesReceived;
break;
}
case 'candidate-pair':
{
if (!group.writable)
break;
selectedCandidatePair = group;
transport['available bitrate'] =
Math.round(group.availableOutgoingBitrate / 1000) + ' kbps';
transport['current RTT'] =
Math.round(group.currentRoundTripTime * 1000) + ' ms';
break;
}
case 'local-candidate':
{
localCandidates[group.id] = group;
break;
}
case 'remote-candidate':
{
remoteCandidates[group.id] = group;
break;
}
case 'codec':
{
let mimeType = group.mimeType.split('/');
let kind = mimeType[0];
let codec = mimeType[1];
let block;
switch (kind)
{
case 'audio':
block = audio;
break;
case 'video':
if (codec === 'rtx')
break;
block = video;
break;
}
if (!block)
break;
block['codec'] = codec;
block['payload type'] = group.payloadType;
break;
}
case 'track':
{
if (group.kind !== 'video')
break;
video['frame size'] = group.frameWidth + ' x ' + group.frameHeight;
video['frames sent'] = group.framesSent;
break;
}
case 'outbound-rtp':
{
if (group.isRemote)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
if (block === video)
block['frames encoded'] = group.framesEncoded;
block['NACK count'] = group.nackCount;
block['PLI count'] = group.pliCount;
block['FIR count'] = group.firCount;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
if (selectedCandidatePair)
{
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
transport['protocol'] = localCandidate.protocol;
transport['local IP'] = localCandidate.ip;
transport['local port'] = localCandidate.port;
transport['remote IP'] = remoteCandidate.ip;
transport['remote port'] = remoteCandidate.port;
}
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
_processStatsChromeOld(stats)
{
let transport = {};
let audio = {};
let video = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'googCandidatePair':
{
if (group.googActiveConnection !== 'true')
break;
let localAddress = group.googLocalAddress.split(':');
let remoteAddress = group.googRemoteAddress.split(':');
let localIP = localAddress[0];
let localPort = localAddress[1];
let remoteIP = remoteAddress[0];
let remotePort = remoteAddress[1];
transport['protocol'] = group.googTransportType;
transport['local IP'] = localIP;
transport['local port'] = localPort;
transport['remote IP'] = remoteIP;
transport['remote port'] = remotePort;
transport['bytes sent'] = group.bytesSent;
transport['bytes received'] = group.bytesReceived;
transport['RTT'] = Math.round(group.googRtt) + ' ms';
break;
}
case 'VideoBwe':
{
transport['available bitrate'] =
Math.round(group.googAvailableSendBandwidth / 1000) + ' kbps';
transport['transmit bitrate'] =
Math.round(group.googTransmitBitrate / 1000) + ' kbps';
break;
}
case 'ssrc':
{
if (group.packetsSent === undefined)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['codec'] = group.googCodecName;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
block['packets lost'] = group.packetsLost;
if (block === video)
{
block['frames encoded'] = group.framesEncoded;
video['frame size'] =
group.googFrameWidthSent + ' x ' + group.googFrameHeightSent;
video['frame rate'] = group.googFrameRateSent;
}
block['NACK count'] = group.googNacksReceived;
block['PLI count'] = group.googPlisReceived;
block['FIR count'] = group.googFirsReceived;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
_processStatsFirefox(stats)
{
let transport = {};
let audio = {};
let video = {};
let selectedCandidatePair = null;
let localCandidates = {};
let remoteCandidates = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'candidate-pair':
{
if (!group.selected)
break;
selectedCandidatePair = group;
break;
}
case 'local-candidate':
{
localCandidates[group.id] = group;
break;
}
case 'remote-candidate':
{
remoteCandidates[group.id] = group;
break;
}
case 'outbound-rtp':
{
if (group.isRemote)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
if (block === video)
{
block['bitrate'] =
Math.round(group.bitrateMean / 1000) + ' kbps';
block['frames encoded'] = group.framesEncoded;
video['frame rate'] = Math.round(group.framerateMean);
}
block['NACK count'] = group.nackCount;
block['PLI count'] = group.pliCount;
block['FIR count'] = group.firCount;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
if (selectedCandidatePair)
{
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
transport['protocol'] = localCandidate.transport;
transport['local IP'] = localCandidate.ipAddress;
transport['local port'] = localCandidate.portNumber;
transport['remote IP'] = remoteCandidate.ipAddress;
transport['remote port'] = remoteCandidate.portNumber;
}
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
_processStatsSafari11(stats)
{
let transport = {};
let audio = {};
let video = {};
for (let group of stats.values())
{
switch (group.type)
{
case 'candidate-pair':
{
if (!group.writable)
break;
transport['bytes sent'] = group.bytesSent;
transport['bytes received'] = group.bytesReceived;
transport['available bitrate'] =
Math.round(group.availableOutgoingBitrate / 1000) + ' kbps';
transport['current RTT'] =
Math.round(group.currentRoundTripTime * 1000) + ' ms';
break;
}
case 'outbound-rtp':
{
if (group.isRemote)
break;
let block;
switch (group.mediaType)
{
case 'audio':
block = audio;
break;
case 'video':
block = video;
break;
}
if (!block)
break;
block['ssrc'] = group.ssrc;
block['bytes sent'] = group.bytesSent;
block['packets sent'] = group.packetsSent;
if (block === video)
block['frames encoded'] = group.framesEncoded;
block['NACK count'] = group.nackCount;
block['PLI count'] = group.pliCount;
block['FIR count'] = group.firCount;
break;
}
}
}
// Post checks.
if (!video.ssrc)
video = {};
if (!audio.ssrc)
audio = {};
// Set state.
this.setState(
{
stats :
{
transport,
audio,
video
}
});
}
}
Stats.propTypes =
{
stats : PropTypes.object.isRequired,
onClose : PropTypes.func.isRequired
};
-60
View File
@@ -1,60 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import TransitionGroup from 'react-transition-group/TransitionGroup';
const DEFAULT_DURATION = 1000;
export default class TransitionAppear extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
let props = this.props;
let duration = props.hasOwnProperty('duration') ? props.duration : DEFAULT_DURATION;
return (
<TransitionGroup
component={FakeTransitionWrapper}
transitionName='transition'
transitionAppear={!!duration}
transitionAppearTimeout={duration}
transitionEnter={false}
transitionLeave={false}
>
{this.props.children}
</TransitionGroup>
);
}
}
TransitionAppear.propTypes =
{
children : PropTypes.any,
duration : PropTypes.number
};
class FakeTransitionWrapper extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
let children = React.Children.toArray(this.props.children);
return children[0] || null;
}
}
FakeTransitionWrapper.propTypes =
{
children : PropTypes.any
};
-241
View File
@@ -1,241 +0,0 @@
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import Logger from '../Logger';
import classnames from 'classnames';
import hark from 'hark';
const logger = new Logger('Video'); // eslint-disable-line no-unused-vars
export default class Video extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
width : 0,
height : 0,
resolution : null,
volume : 0 // Integer from 0 to 10.
};
let stream = props.stream;
// Clean stream.
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this._cleanStream(stream);
// Current MediaStreamTracks info.
this._tracksHash = this._getTracksHash(stream);
// Periodic timer to show video dimensions.
this._videoResolutionTimer = null;
// Hark instance.
this._hark = null;
}
render()
{
let props = this.props;
let state = this.state;
return (
<div data-component='Video'>
{state.width ?
(
<div
className={classnames('resolution', { clickable: !!props.resolution })}
onClick={this.handleResolutionClick.bind(this)}
>
<p>{state.width}x{state.height}</p>
{props.resolution ?
<p>{props.resolution}</p>
:null}
</div>
)
:null}
<div className='volume'>
<div className={classnames('bar', `level${state.volume}`)}/>
</div>
<video
ref='video'
className={classnames(
{
mirror : props.mirror,
hidden : props.videoDisabled
})}
autoPlay
muted={props.muted}
/>
</div>
);
}
componentDidMount()
{
let stream = this.props.stream;
let video = this.refs.video;
video.srcObject = stream;
this._showVideoResolution();
this._videoResolutionTimer = setInterval(() =>
{
this._showVideoResolution();
}, 500);
if (stream.getAudioTracks().length > 0)
{
this._hark = hark(stream);
this._hark.on('speaking', () =>
{
// logger.debug('hark "speaking" event');
});
this._hark.on('stopped_speaking', () =>
{
// logger.debug('hark "stopped_speaking" event');
this.setState({ volume: 0 });
});
this._hark.on('volume_change', (volume, threshold) =>
{
if (volume < threshold)
return;
// logger.debug('hark "volume_change" event [volume:%sdB, threshold:%sdB]', volume, threshold);
this.setState(
{
volume : Math.round((volume - threshold) * (-10) / threshold)
});
});
}
}
componentWillUnmount()
{
clearInterval(this._videoResolutionTimer);
if (this._hark)
this._hark.stop();
}
componentWillReceiveProps(nextProps)
{
let stream = nextProps.stream;
// Clean stream.
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
this._cleanStream(stream);
// If there is something different in the stream, re-render it.
let previousTracksHash = this._tracksHash;
this._tracksHash = this._getTracksHash(stream);
if (this._tracksHash !== previousTracksHash)
this.refs.video.srcObject = stream;
}
handleResolutionClick()
{
if (!this.props.resolution)
return;
logger.debug('handleResolutionClick()');
this.props.onResolutionChange();
}
_getTracksHash(stream)
{
return stream.getTracks()
.map((track) =>
{
let trackId = track.jitsiRemoteId || track.id;
return trackId;
})
.join('|');
}
_showVideoResolution()
{
let video = this.refs.video;
this.setState(
{
width : video.videoWidth,
height : video.videoHeight
});
}
_cleanStream(stream)
{
// Hack for Firefox bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
if (!stream)
return;
let tracks = stream.getTracks();
let previousNumTracks = tracks.length;
// Remove ended tracks.
for (let track of tracks)
{
if (track.readyState === 'ended')
{
logger.warn('_cleanStream() | removing ended track [track:%o]', track);
stream.removeTrack(track);
}
}
// If there are multiple live audio tracks (related to the bug?) just keep
// the last one.
while (stream.getAudioTracks().length > 1)
{
let track = stream.getAudioTracks()[0];
logger.warn('_cleanStream() | removing live audio track due the presence of others [track:%o]', track);
stream.removeTrack(track);
}
// If there are multiple live video tracks (related to the bug?) just keep
// the last one.
while (stream.getVideoTracks().length > 1)
{
let track = stream.getVideoTracks()[0];
logger.warn('_cleanStream() | removing live video track due the presence of others [track:%o]', track);
stream.removeTrack(track);
}
let numTracks = stream.getTracks().length;
if (numTracks !== previousNumTracks)
logger.warn('_cleanStream() | num tracks changed from %s to %s', previousNumTracks, numTracks);
}
}
Video.propTypes =
{
stream : PropTypes.object.isRequired,
resolution : PropTypes.string,
muted : PropTypes.bool,
videoDisabled : PropTypes.bool,
mirror : PropTypes.bool,
onResolutionChange : PropTypes.func
};
+71
View File
@@ -0,0 +1,71 @@
import PropTypes from 'prop-types';
export const Room = PropTypes.shape(
{
url : PropTypes.string.isRequired,
state : PropTypes.oneOf(
[ 'new', 'connecting', 'connected', 'closed' ]).isRequired,
activeSpeakerName : PropTypes.string
});
export const Device = PropTypes.shape(
{
flag : PropTypes.string.isRequired,
name : PropTypes.string.isRequired,
version : PropTypes.string
});
export const Me = PropTypes.shape(
{
name : PropTypes.string.isRequired,
displayName : PropTypes.string,
displayNameSet : PropTypes.bool.isRequired,
device : Device.isRequired,
canSendMic : PropTypes.bool.isRequired,
canSendWebcam : PropTypes.bool.isRequired,
canChangeWebcam : PropTypes.bool.isRequired,
webcamInProgress : PropTypes.bool.isRequired,
audioOnly : PropTypes.bool.isRequired,
audioOnlyInProgress : PropTypes.bool.isRequired,
restartIceInProgress : PropTypes.bool.isRequired
});
export const Producer = PropTypes.shape(
{
id : PropTypes.number.isRequired,
source : PropTypes.oneOf([ 'mic', 'webcam' ]).isRequired,
deviceLabel : PropTypes.string,
type : PropTypes.oneOf([ 'front', 'back' ]),
locallyPaused : PropTypes.bool.isRequired,
remotelyPaused : PropTypes.bool.isRequired,
track : PropTypes.any,
codec : PropTypes.string.isRequired
});
export const Peer = PropTypes.shape(
{
name : PropTypes.string.isRequired,
displayName : PropTypes.string,
device : Device.isRequired,
consumers : PropTypes.arrayOf(PropTypes.number).isRequired
});
export const Consumer = PropTypes.shape(
{
id : PropTypes.number.isRequired,
peerName : PropTypes.string.isRequired,
source : PropTypes.oneOf([ 'mic', 'webcam' ]).isRequired,
supported : PropTypes.bool.isRequired,
locallyPaused : PropTypes.bool.isRequired,
remotelyPaused : PropTypes.bool.isRequired,
profile : PropTypes.oneOf([ 'none', 'low', 'medium', 'high' ]),
track : PropTypes.any,
codec : PropTypes.string
});
export const Notification = PropTypes.shape(
{
id : PropTypes.string.isRequired,
type : PropTypes.oneOf([ 'info', 'error' ]).isRequired,
timeout : PropTypes.number
});
-16
View File
@@ -1,16 +0,0 @@
'use strict';
import getMuiTheme from 'material-ui/styles/getMuiTheme';
import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
import { grey500 } from 'material-ui/styles/colors';
// NOTE: I should clone it
let theme = lightBaseTheme;
theme.palette.borderColor = grey500;
let muiTheme = getMuiTheme(lightBaseTheme);
export default muiTheme;
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CSSTransition } from 'react-transition-group';
const Appear = ({ duration, children }) => (
<CSSTransition
in
classNames='Appear'
timeout={duration || 1000}
appear
>
{children}
</CSSTransition>
);
Appear.propTypes =
{
duration : PropTypes.number,
children : PropTypes.any
};
export { Appear };
+24
View File
@@ -0,0 +1,24 @@
import jsCookie from 'js-cookie';
const USER_COOKIE = 'mediasoup-demo.user';
const DEVICES_COOKIE = 'mediasoup-demo.devices';
export function getUser()
{
return jsCookie.getJSON(USER_COOKIE);
}
export function setUser({ displayName })
{
jsCookie.set(USER_COOKIE, { displayName });
}
export function getDevices()
{
return jsCookie.getJSON(DEVICES_COOKIE);
}
export function setDevices({ webcamEnabled })
{
jsCookie.set(DEVICES_COOKIE, { webcamEnabled });
}
File diff suppressed because it is too large Load Diff
-105
View File
@@ -1,105 +0,0 @@
import sdpTransform from 'sdp-transform';
/**
* RTCSessionDescription implementation.
*/
export default class RTCSessionDescription {
/**
* RTCSessionDescription constructor.
* @param {Object} [data]
* @param {String} [data.type] - 'offer' / 'answer'.
* @param {String} [data.sdp] - SDP string.
* @param {Object} [data._sdpObject] - SDP object generated by the
* sdp-transform library.
*/
constructor(data) {
// @type {String}
this._sdp = null;
// @type {Object}
this._sdpObject = null;
// @type {String}
this._type = null;
switch (data.type) {
case 'offer':
break;
case 'answer':
break;
default:
throw new TypeError(`invalid type "${data.type}"`);
}
this._type = data.type;
if (typeof data.sdp === 'string') {
this._sdp = data.sdp;
try {
this._sdpObject = sdpTransform.parse(data.sdp);
} catch (error) {
throw new Error(`invalid sdp: ${error}`);
}
} else if (typeof data._sdpObject === 'object') {
this._sdpObject = data._sdpObject;
try {
this._sdp = sdpTransform.write(data._sdpObject);
} catch (error) {
throw new Error(`invalid sdp object: ${error}`);
}
} else {
throw new TypeError('invalid sdp or _sdpObject');
}
}
/**
* Get sdp field.
* @return {String}
*/
get sdp() {
return this._sdp;
}
/**
* Set sdp field.
* NOTE: This is not allowed per spec, but lib-jitsi-meet uses it.
* @param {String} sdp
*/
set sdp(sdp) {
try {
this._sdpObject = sdpTransform.parse(sdp);
} catch (error) {
throw new Error(`invalid sdp: ${error}`);
}
this._sdp = sdp;
}
/**
* Gets the internal sdp object.
* @return {Object}
* @private
*/
get sdpObject() {
return this._sdpObject;
}
/**
* Get type field.
* @return {String}
*/
get type() {
return this._type;
}
/**
* Returns an object with type and sdp fields.
* @return {Object}
*/
toJSON() {
return {
sdp: this._sdp,
type: this._type
};
}
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Create a class inheriting from Error.
*/
function createErrorClass(name) {
const klass = class extends Error {
/**
* Custom error class constructor.
* @param {string} message
*/
constructor(message) {
super(message);
// Override `name` property value and make it non enumerable.
Object.defineProperty(this, 'name', { value: name });
}
};
return klass;
}
export const InvalidStateError = createErrorClass('InvalidStateError');
-458
View File
@@ -1,458 +0,0 @@
/* global RTCRtpReceiver */
import sdpTransform from 'sdp-transform';
/**
* Extract RTP capabilities from remote description.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {RTCRtpCapabilities}
*/
export function extractCapabilities(sdpObject) {
// Map of RtpCodecParameters indexed by payload type.
const codecsMap = new Map();
// Array of RtpHeaderExtensions.
const headerExtensions = [];
for (const m of sdpObject.media) {
// Media kind.
const kind = m.type;
if (kind !== 'audio' && kind !== 'video') {
continue; // eslint-disable-line no-continue
}
// Get codecs.
for (const rtp of m.rtp) {
const codec = {
clockRate: rtp.rate,
kind,
mimeType: `${kind}/${rtp.codec}`,
name: rtp.codec,
numChannels: rtp.encoding || 1,
parameters: {},
preferredPayloadType: rtp.payload,
rtcpFeedback: []
};
codecsMap.set(codec.preferredPayloadType, codec);
}
// Get codec parameters.
for (const fmtp of m.fmtp || []) {
const parameters = sdpTransform.parseFmtpConfig(fmtp.config);
const codec = codecsMap.get(fmtp.payload);
if (!codec) {
continue; // eslint-disable-line no-continue
}
codec.parameters = parameters;
}
// Get RTCP feedback for each codec.
for (const fb of m.rtcpFb || []) {
const codec = codecsMap.get(fb.payload);
if (!codec) {
continue; // eslint-disable-line no-continue
}
codec.rtcpFeedback.push({
parameter: fb.subtype || '',
type: fb.type
});
}
// Get RTP header extensions.
for (const ext of m.ext || []) {
const preferredId = ext.value;
const uri = ext.uri;
const headerExtension = {
kind,
uri,
preferredId
};
// Check if already present.
const duplicated = headerExtensions.find(savedHeaderExtension =>
headerExtension.kind === savedHeaderExtension.kind
&& headerExtension.uri === savedHeaderExtension.uri
);
if (!duplicated) {
headerExtensions.push(headerExtension);
}
}
}
return {
codecs: Array.from(codecsMap.values()),
fecMechanisms: [], // TODO
headerExtensions
};
}
/**
* Extract DTLS parameters from remote description.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {RTCDtlsParameters}
*/
export function extractDtlsParameters(sdpObject) {
const media = getFirstActiveMediaSection(sdpObject);
const fingerprint = media.fingerprint || sdpObject.fingerprint;
let role;
switch (media.setup) {
case 'active':
role = 'client';
break;
case 'passive':
role = 'server';
break;
case 'actpass':
role = 'auto';
break;
}
return {
role,
fingerprints: [
{
algorithm: fingerprint.type,
value: fingerprint.hash
}
]
};
}
/**
* Extract ICE candidates from remote description.
* NOTE: This implementation assumes a single BUNDLEd transport and rtcp-mux.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {sequence<RTCIceCandidate>}
*/
export function extractIceCandidates(sdpObject) {
const media = getFirstActiveMediaSection(sdpObject);
const candidates = [];
for (const c of media.candidates) {
// Ignore RTCP candidates (we assume rtcp-mux).
if (c.component !== 1) {
continue; // eslint-disable-line no-continue
}
const candidate = {
foundation: c.foundation,
ip: c.ip,
port: c.port,
priority: c.priority,
protocol: c.transport.toLowerCase(),
type: c.type
};
candidates.push(candidate);
}
return candidates;
}
/**
* Extract ICE parameters from remote description.
* NOTE: This implementation assumes a single BUNDLEd transport.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {RTCIceParameters}
*/
export function extractIceParameters(sdpObject) {
const media = getFirstActiveMediaSection(sdpObject);
const usernameFragment = media.iceUfrag;
const password = media.icePwd;
const icelite = sdpObject.icelite === 'ice-lite';
return {
icelite,
password,
usernameFragment
};
}
/**
* Extract MID values from remote description.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {map<String, String>} Ordered Map with MID as key and kind as value.
*/
export function extractMids(sdpObject) {
const midToKind = new Map();
// Ignore disabled media sections.
for (const m of sdpObject.media) {
midToKind.set(m.mid, m.type);
}
return midToKind;
}
/**
* Extract tracks information.
* @param {Object} sdpObject - Remote SDP object generated by sdp-transform.
* @return {Map}
*/
export function extractTrackInfos(sdpObject) {
// Map with info about receiving media.
// - index: Media SSRC
// - value: Object
// - kind: 'audio' / 'video'
// - ssrc: Media SSRC
// - rtxSsrc: RTX SSRC (may be unset)
// - streamId: MediaStream.jitsiRemoteId
// - trackId: MediaStreamTrack.jitsiRemoteId
// - cname: CNAME
// @type {map<Number, Object>}
const infos = new Map();
// Map with stream SSRC as index and associated RTX SSRC as value.
// @type {map<Number, Number>}
const rtxMap = new Map();
// Set of RTX SSRC values.
const rtxSet = new Set();
for (const m of sdpObject.media) {
const kind = m.type;
if (kind !== 'audio' && kind !== 'video') {
continue; // eslint-disable-line no-continue
}
// Get RTX information.
for (const ssrcGroup of m.ssrcGroups || []) {
// Just consider FID.
if (ssrcGroup.semantics !== 'FID') {
continue; // eslint-disable-line no-continue
}
const ssrcs
= ssrcGroup.ssrcs.split(' ').map(ssrc => Number(ssrc));
const ssrc = ssrcs[0];
const rtxSsrc = ssrcs[1];
rtxMap.set(ssrc, rtxSsrc);
rtxSet.add(rtxSsrc);
}
for (const ssrcObject of m.ssrcs || []) {
const ssrc = ssrcObject.id;
// Ignore RTX.
if (rtxSet.has(ssrc)) {
continue; // eslint-disable-line no-continue
}
let info = infos.get(ssrc);
if (!info) {
info = {
kind,
rtxSsrc: rtxMap.get(ssrc),
ssrc
};
infos.set(ssrc, info);
}
switch (ssrcObject.attribute) {
case 'cname': {
info.cname = ssrcObject.value;
break;
}
case 'msid': {
const values = ssrcObject.value.split(' ');
const streamId = values[0];
const trackId = values[1];
info.streamId = streamId;
info.trackId = trackId;
break;
}
case 'mslabel': {
const streamId = ssrcObject.value;
info.streamId = streamId;
break;
}
case 'label': {
const trackId = ssrcObject.value;
info.trackId = trackId;
break;
}
}
}
}
return infos;
}
/**
* Get local ORTC RTP capabilities filtered and adapted to the given remote RTP
* capabilities.
* @param {RTCRtpCapabilities} filterWithCapabilities - RTP capabilities to
* filter with.
* @return {RTCRtpCapabilities}
*/
export function getLocalCapabilities(filterWithCapabilities) {
const localFullCapabilities = RTCRtpReceiver.getCapabilities();
const localCapabilities = {
codecs: [],
fecMechanisms: [],
headerExtensions: []
};
// Map of RTX and codec payloads.
// - index: Codec payloadType
// - value: Associated RTX payloadType
// @type {map<Number, Number>}
const remoteRtxMap = new Map();
// Set codecs.
for (const remoteCodec of filterWithCapabilities.codecs) {
const remoteCodecName = remoteCodec.name.toLowerCase();
if (remoteCodecName === 'rtx') {
remoteRtxMap.set(
remoteCodec.parameters.apt, remoteCodec.preferredPayloadType);
continue; // eslint-disable-line no-continue
}
const localCodec = localFullCapabilities.codecs.find(codec =>
codec.name.toLowerCase() === remoteCodecName
&& codec.kind === remoteCodec.kind
&& codec.clockRate === remoteCodec.clockRate
);
if (!localCodec) {
continue; // eslint-disable-line no-continue
}
const codec = {
clockRate: localCodec.clockRate,
kind: localCodec.kind,
mimeType: `${localCodec.kind}/${localCodec.name}`,
name: localCodec.name,
numChannels: localCodec.numChannels || 1,
parameters: {},
preferredPayloadType: remoteCodec.preferredPayloadType,
rtcpFeedback: []
};
for (const remoteParamName of Object.keys(remoteCodec.parameters)) {
const remoteParamValue
= remoteCodec.parameters[remoteParamName];
for (const localParamName of Object.keys(localCodec.parameters)) {
const localParamValue
= localCodec.parameters[localParamName];
if (localParamName !== remoteParamName) {
continue; // eslint-disable-line no-continue
}
// TODO: We should consider much more cases here, but Edge
// does not support many codec parameters.
if (localParamValue === remoteParamValue) {
// Use this RTP parameter.
codec.parameters[localParamName] = localParamValue;
break;
}
}
}
for (const remoteFb of remoteCodec.rtcpFeedback) {
const localFb = localCodec.rtcpFeedback.find(fb =>
fb.type === remoteFb.type
&& fb.parameter === remoteFb.parameter
);
if (localFb) {
// Use this RTCP feedback.
codec.rtcpFeedback.push(localFb);
}
}
// Use this codec.
localCapabilities.codecs.push(codec);
}
// Add RTX for video codecs.
for (const codec of localCapabilities.codecs) {
const payloadType = codec.preferredPayloadType;
if (!remoteRtxMap.has(payloadType)) {
continue; // eslint-disable-line no-continue
}
const rtxCodec = {
clockRate: codec.clockRate,
kind: codec.kind,
mimeType: `${codec.kind}/rtx`,
name: 'rtx',
parameters: {
apt: payloadType
},
preferredPayloadType: remoteRtxMap.get(payloadType),
rtcpFeedback: []
};
// Add RTX codec.
localCapabilities.codecs.push(rtxCodec);
}
// Add RTP header extensions.
for (const remoteExtension of filterWithCapabilities.headerExtensions) {
const localExtension
= localFullCapabilities.headerExtensions.find(extension =>
extension.kind === remoteExtension.kind
&& extension.uri === remoteExtension.uri
);
if (localExtension) {
const extension = {
kind: localExtension.kind,
preferredEncrypt: Boolean(remoteExtension.preferredEncrypt),
preferredId: remoteExtension.preferredId,
uri: localExtension.uri
};
// Use this RTP header extension.
localCapabilities.headerExtensions.push(extension);
}
}
// Add FEC mechanisms.
// NOTE: We don't support FEC yet and, in fact, neither does Edge.
for (const remoteFecMechanism of filterWithCapabilities.fecMechanisms) {
const localFecMechanism
= localFullCapabilities.fecMechanisms.find(fec =>
fec === remoteFecMechanism
);
if (localFecMechanism) {
// Use this FEC mechanism.
localCapabilities.fecMechanisms.push(localFecMechanism);
}
}
return localCapabilities;
}
/**
* Get the first acive media section.
* @param {Object} sdpObject - SDP object generated by sdp-transform.
* @return {Object} SDP media section as parsed by sdp-transform.
*/
function getFirstActiveMediaSection(sdpObject) {
return sdpObject.media.find(m =>
m.iceUfrag && m.port !== 0
);
}
+150 -41
View File
@@ -1,74 +1,183 @@
'use strict';
import browser from 'bowser';
import domready from 'domready';
import UrlParse from 'url-parse';
import React from 'react';
import ReactDOM from 'react-dom';
import injectTapEventPlugin from 'react-tap-event-plugin';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import {
applyMiddleware as applyReduxMiddleware,
createStore as createReduxStore
} from 'redux';
import thunk from 'redux-thunk';
import { createLogger as createReduxLogger } from 'redux-logger';
import { getDeviceInfo } from 'mediasoup-client';
import randomString from 'random-string';
import randomName from 'node-random-name';
import Logger from './Logger';
import * as utils from './utils';
import edgeRTCPeerConnection from './edge/RTCPeerConnection';
import edgeRTCSessionDescription from './edge/RTCSessionDescription';
import App from './components/App';
import * as cookiesManager from './cookiesManager';
import * as requestActions from './redux/requestActions';
import * as stateActions from './redux/stateActions';
import reducers from './redux/reducers';
import roomClientMiddleware from './redux/roomClientMiddleware';
import Room from './components/Room';
const REGEXP_FRAGMENT_ROOM_ID = new RegExp('^#room-id=([0-9a-zA-Z_-]+)$');
const logger = new Logger();
const reduxMiddlewares =
[
thunk,
roomClientMiddleware
];
injectTapEventPlugin();
logger.debug('detected browser [name:"%s", version:%s]', browser.name, browser.version);
// If Edge, use the Jitsi RTCPeerConnection shim.
if (browser.msedge)
if (process.env.NODE_ENV === 'development')
{
logger.debug('Edge detected, overriding RTCPeerConnection and RTCSessionDescription');
const reduxLogger = createReduxLogger(
{
duration : true,
timestamp : false,
level : 'log',
logErrors : true
});
window.RTCPeerConnection = edgeRTCPeerConnection;
window.RTCSessionDescription = edgeRTCSessionDescription;
}
// Otherwise, do almost anything.
else
{
window.RTCPeerConnection =
window.webkitRTCPeerConnection ||
window.mozRTCPeerConnection ||
window.RTCPeerConnection;
reduxMiddlewares.push(reduxLogger);
}
const store = createReduxStore(
reducers,
undefined,
applyReduxMiddleware(...reduxMiddlewares)
);
domready(() =>
{
logger.debug('DOM ready');
// Load stuff and run
utils.initialize()
.then(run)
.catch((error) =>
{
console.error(error);
});
.then(run);
});
function run()
{
logger.debug('run() [environment:%s]', process.env.NODE_ENV);
let container = document.getElementById('mediasoup-demo-app-container');
let urlParser = new UrlParse(window.location.href, true);
let match = urlParser.hash.match(REGEXP_FRAGMENT_ROOM_ID);
let peerId = randomString({ length: 8 }).toLowerCase();
let roomId;
const peerName = randomString({ length: 8 }).toLowerCase();
const urlParser = new UrlParse(window.location.href, true);
let roomId = urlParser.query.roomId;
const produce = urlParser.query.produce !== 'false';
let displayName = urlParser.query.displayName;
const isSipEndpoint = urlParser.query.sipEndpoint === 'true';
const useSimulcast = urlParser.query.simulcast !== 'false';
if (match)
if (!roomId)
{
roomId = match[1];
roomId = randomString({ length: 8 }).toLowerCase();
urlParser.query.roomId = roomId;
window.history.pushState('', '', urlParser.toString());
}
// Get the effective/shareable Room URL.
const roomUrlParser = new UrlParse(window.location.href, true);
for (const key of Object.keys(roomUrlParser.query))
{
// Don't keep some custom params.
switch (key)
{
case 'roomId':
case 'simulcast':
break;
default:
delete roomUrlParser.query[key];
}
}
delete roomUrlParser.hash;
const roomUrl = roomUrlParser.toString();
// Get displayName from cookie (if not already given as param).
const userCookie = cookiesManager.getUser() || {};
let displayNameSet;
if (!displayName)
displayName = userCookie.displayName;
if (displayName)
{
displayNameSet = true;
}
else
{
roomId = randomString({ length: 8 }).toLowerCase();
window.location = `#room-id=${roomId}`;
displayName = randomName();
displayNameSet = false;
}
ReactDOM.render(<App peerId={peerId} roomId={roomId}/>, container);
// Get current device.
const device = getDeviceInfo();
// If a SIP endpoint mangle device info.
if (isSipEndpoint)
{
device.flag = 'sipendpoint';
device.name = 'SIP Endpoint';
device.version = undefined;
}
// NOTE: I don't like this.
store.dispatch(
stateActions.setRoomUrl(roomUrl));
// NOTE: I don't like this.
store.dispatch(
stateActions.setMe({ peerName, displayName, displayNameSet, device }));
// NOTE: I don't like this.
store.dispatch(
requestActions.joinRoom(
{ roomId, peerName, displayName, device, useSimulcast, produce }));
render(
<Provider store={store}>
<Room />
</Provider>,
document.getElementById('mediasoup-demo-app-container')
);
}
// TODO: Debugging stuff.
setInterval(() =>
{
if (!global.CLIENT._room.peers[0])
{
delete global.CONSUMER;
return;
}
const peer = global.CLIENT._room.peers[0];
global.CONSUMER = peer.consumers[peer.consumers.length - 1];
}, 2000);
global.sendSdp = function()
{
logger.debug('---------- SEND_TRANSPORT LOCAL SDP OFFER:');
logger.debug(
global.CLIENT._sendTransport._handler._pc.localDescription.sdp);
logger.debug('---------- SEND_TRANSPORT REMOTE SDP ANSWER:');
logger.debug(
global.CLIENT._sendTransport._handler._pc.remoteDescription.sdp);
};
global.recvSdp = function()
{
logger.debug('---------- RECV_TRANSPORT REMOTE SDP OFFER:');
logger.debug(
global.CLIENT._recvTransport._handler._pc.remoteDescription.sdp);
logger.debug('---------- RECV_TRANSPORT LOCAL SDP ANSWER:');
logger.debug(
global.CLIENT._recvTransport._handler._pc.localDescription.sdp);
};
+99
View File
@@ -0,0 +1,99 @@
# APP STATE
```js
{
room :
{
url : 'https://example.io/?&roomId=d0el8y34',
state : 'connected', // new/connecting/connected/closed
activeSpeakerName : 'alice'
},
me :
{
name : 'bob',
displayName : 'Bob McFLower',
displayNameSet : false, // true if got from cookie or manually set.
device : { flag: 'firefox', name: 'Firefox', version: '61' },
canSendMic : true,
canSendWebcam : true,
canChangeWebcam : false,
webcamInProgress : false,
audioOnly : false,
audioOnlyInProgress : false,
restartIceInProgress : false
},
producers :
{
1111 :
{
id : 1111,
source : 'mic', // mic/webcam,
locallyPaused : true,
remotelyPaused : false,
track : MediaStreamTrack,
codec : 'opus'
},
1112 :
{
id : 1112,
source : 'webcam', // mic/webcam
deviceLabel : 'Macbook Webcam',
type : 'front', // front/back
locallyPaused : false,
remotelyPaused : false,
track : MediaStreamTrack,
codec : 'vp8',
}
},
peers :
{
'alice' :
{
name : 'alice',
displayName : 'Alice Thomsom',
device : { flag: 'chrome', name: 'Chrome', version: '58' },
consumers : [ 5551, 5552 ]
}
},
consumers :
{
5551 :
{
id : 5551,
peerName : 'alice',
source : 'mic', // mic/webcam
supported : true,
locallyPaused : false,
remotelyPaused : false,
profile : 'default',
track : MediaStreamTrack,
codec : 'opus'
},
5552 :
{
id : 5552,
peerName : 'alice',
source : 'webcam',
supported : false,
locallyPaused : false,
remotelyPaused : true,
profile : 'medium',
track : null,
codec : 'h264'
}
},
notifications :
[
{
id : 'qweasdw43we',
type : 'info' // info/error
text : 'You joined the room'
},
{
id : 'j7sdhkjjkcc',
type : 'error'
text : 'Could not add webcam'
}
]
}
```
+75
View File
@@ -0,0 +1,75 @@
const initialState = {};
const consumers = (state = initialState, action) =>
{
switch (action.type)
{
case 'ADD_CONSUMER':
{
const { consumer } = action.payload;
return { ...state, [consumer.id]: consumer };
}
case 'REMOVE_CONSUMER':
{
const { consumerId } = action.payload;
const newState = { ...state };
delete newState[consumerId];
return newState;
}
case 'SET_CONSUMER_PAUSED':
{
const { consumerId, originator } = action.payload;
const consumer = state[consumerId];
let newConsumer;
if (originator === 'local')
newConsumer = { ...consumer, locallyPaused: true };
else
newConsumer = { ...consumer, remotelyPaused: true };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_RESUMED':
{
const { consumerId, originator } = action.payload;
const consumer = state[consumerId];
let newConsumer;
if (originator === 'local')
newConsumer = { ...consumer, locallyPaused: false };
else
newConsumer = { ...consumer, remotelyPaused: false };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_EFFECTIVE_PROFILE':
{
const { consumerId, profile } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, profile };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_TRACK':
{
const { consumerId, track } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, track };
return { ...state, [consumerId]: newConsumer };
}
default:
return state;
}
};
export default consumers;
+19
View File
@@ -0,0 +1,19 @@
import { combineReducers } from 'redux';
import room from './room';
import me from './me';
import producers from './producers';
import peers from './peers';
import consumers from './consumers';
import notifications from './notifications';
const reducers = combineReducers(
{
room,
me,
producers,
peers,
consumers,
notifications
});
export default reducers;
+85
View File
@@ -0,0 +1,85 @@
const initialState =
{
name : null,
displayName : null,
displayNameSet : false,
device : null,
canSendMic : false,
canSendWebcam : false,
canChangeWebcam : false,
webcamInProgress : false,
audioOnly : false,
audioOnlyInProgress : false,
restartIceInProgress : false
};
const me = (state = initialState, action) =>
{
switch (action.type)
{
case 'SET_ME':
{
const { peerName, displayName, displayNameSet, device } = action.payload;
return { ...state, name: peerName, displayName, displayNameSet, device };
}
case 'SET_MEDIA_CAPABILITIES':
{
const { canSendMic, canSendWebcam } = action.payload;
return { ...state, canSendMic, canSendWebcam };
}
case 'SET_CAN_CHANGE_WEBCAM':
{
const canChangeWebcam = action.payload;
return { ...state, canChangeWebcam };
}
case 'SET_WEBCAM_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, webcamInProgress: flag };
}
case 'SET_DISPLAY_NAME':
{
let { displayName } = action.payload;
// Be ready for undefined displayName (so keep previous one).
if (!displayName)
displayName = state.displayName;
return { ...state, displayName, displayNameSet: true };
}
case 'SET_AUDIO_ONLY_STATE':
{
const { enabled } = action.payload;
return { ...state, audioOnly: enabled };
}
case 'SET_AUDIO_ONLY_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, audioOnlyInProgress: flag };
}
case 'SET_RESTART_ICE_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, restartIceInProgress: flag };
}
default:
return state;
}
};
export default me;
+31
View File
@@ -0,0 +1,31 @@
const initialState = [];
const notifications = (state = initialState, action) =>
{
switch (action.type)
{
case 'ADD_NOTIFICATION':
{
const { notification } = action.payload;
return [ ...state, notification ];
}
case 'REMOVE_NOTIFICATION':
{
const { notificationId } = action.payload;
return state.filter((notification) => notification.id !== notificationId);
}
case 'REMOVE_ALL_NOTIFICATIONS':
{
return [];
}
default:
return state;
}
};
export default notifications;
+79
View File
@@ -0,0 +1,79 @@
const initialState = {};
const peers = (state = initialState, action) =>
{
switch (action.type)
{
case 'ADD_PEER':
{
const { peer } = action.payload;
return { ...state, [peer.name]: peer };
}
case 'REMOVE_PEER':
{
const { peerName } = action.payload;
const newState = { ...state };
delete newState[peerName];
return newState;
}
case 'SET_PEER_DISPLAY_NAME':
{
const { displayName, peerName } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found');
const newPeer = { ...peer, displayName };
return { ...state, [newPeer.name]: newPeer };
}
case 'ADD_CONSUMER':
{
const { consumer, peerName } = action.payload;
const peer = state[peerName];
if (!peer)
throw new Error('no Peer found for new Consumer');
const newConsumers = [ ...peer.consumers, consumer.id ];
const newPeer = { ...peer, consumers: newConsumers };
return { ...state, [newPeer.name]: newPeer };
}
case 'REMOVE_CONSUMER':
{
const { consumerId, peerName } = action.payload;
const peer = state[peerName];
// NOTE: This means that the Peer was closed before, so it's ok.
if (!peer)
return state;
const idx = peer.consumers.indexOf(consumerId);
if (idx === -1)
throw new Error('Consumer not found');
const newConsumers = peer.consumers.slice();
newConsumers.splice(idx, 1);
const newPeer = { ...peer, consumers: newConsumers };
return { ...state, [newPeer.name]: newPeer };
}
default:
return state;
}
};
export default peers;
+66
View File
@@ -0,0 +1,66 @@
const initialState = {};
const producers = (state = initialState, action) =>
{
switch (action.type)
{
case 'ADD_PRODUCER':
{
const { producer } = action.payload;
return { ...state, [producer.id]: producer };
}
case 'REMOVE_PRODUCER':
{
const { producerId } = action.payload;
const newState = { ...state };
delete newState[producerId];
return newState;
}
case 'SET_PRODUCER_PAUSED':
{
const { producerId, originator } = action.payload;
const producer = state[producerId];
let newProducer;
if (originator === 'local')
newProducer = { ...producer, locallyPaused: true };
else
newProducer = { ...producer, remotelyPaused: true };
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_RESUMED':
{
const { producerId, originator } = action.payload;
const producer = state[producerId];
let newProducer;
if (originator === 'local')
newProducer = { ...producer, locallyPaused: false };
else
newProducer = { ...producer, remotelyPaused: false };
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_TRACK':
{
const { producerId, track } = action.payload;
const producer = state[producerId];
const newProducer = { ...producer, track };
return { ...state, [producerId]: newProducer };
}
default:
return state;
}
};
export default producers;
+41
View File
@@ -0,0 +1,41 @@
const initialState =
{
url : null,
state : 'new', // new/connecting/connected/disconnected/closed,
activeSpeakerName : null
};
const room = (state = initialState, action) =>
{
switch (action.type)
{
case 'SET_ROOM_URL':
{
const { url } = action.payload;
return { ...state, url };
}
case 'SET_ROOM_STATE':
{
const roomState = action.payload.state;
if (roomState == 'connected')
return { ...state, state: roomState };
else
return { ...state, state: roomState, activeSpeakerName: null };
}
case 'SET_ROOM_ACTIVE_SPEAKER':
{
const { peerName } = action.payload;
return { ...state, activeSpeakerName: peerName };
}
default:
return state;
}
};
export default room;
+117
View File
@@ -0,0 +1,117 @@
import randomString from 'random-string';
import * as stateActions from './stateActions';
export const joinRoom = (
{ roomId, peerName, displayName, device, useSimulcast, produce }) =>
{
return {
type : 'JOIN_ROOM',
payload : { roomId, peerName, displayName, device, useSimulcast, produce }
};
};
export const leaveRoom = () =>
{
return {
type : 'LEAVE_ROOM'
};
};
export const changeDisplayName = (displayName) =>
{
return {
type : 'CHANGE_DISPLAY_NAME',
payload : { displayName }
};
};
export const muteMic = () =>
{
return {
type : 'MUTE_MIC'
};
};
export const unmuteMic = () =>
{
return {
type : 'UNMUTE_MIC'
};
};
export const enableWebcam = () =>
{
return {
type : 'ENABLE_WEBCAM'
};
};
export const disableWebcam = () =>
{
return {
type : 'DISABLE_WEBCAM'
};
};
export const changeWebcam = () =>
{
return {
type : 'CHANGE_WEBCAM'
};
};
export const enableAudioOnly = () =>
{
return {
type : 'ENABLE_AUDIO_ONLY'
};
};
export const disableAudioOnly = () =>
{
return {
type : 'DISABLE_AUDIO_ONLY'
};
};
export const restartIce = () =>
{
return {
type : 'RESTART_ICE'
};
};
// This returns a redux-thunk action (a function).
export const notify = ({ type = 'info', text, timeout }) =>
{
if (!timeout)
{
switch (type)
{
case 'info':
timeout = 3000;
break;
case 'error':
timeout = 5000;
break;
}
}
const notification =
{
id : randomString({ length: 6 }).toLowerCase(),
type : type,
text : text,
timeout : timeout
};
return (dispatch) =>
{
dispatch(stateActions.addNotification(notification));
setTimeout(() =>
{
dispatch(stateActions.removeNotification(notification.id));
}, timeout);
};
};
+115
View File
@@ -0,0 +1,115 @@
import RoomClient from '../RoomClient';
export default ({ dispatch, getState }) => (next) =>
{
let client;
return (action) =>
{
switch (action.type)
{
case 'JOIN_ROOM':
{
const {
roomId,
peerName,
displayName,
device,
useSimulcast,
produce
} = action.payload;
client = new RoomClient(
{
roomId,
peerName,
displayName,
device,
useSimulcast,
produce,
dispatch,
getState
});
// TODO: TMP
global.CLIENT = client;
break;
}
case 'LEAVE_ROOM':
{
client.close();
break;
}
case 'CHANGE_DISPLAY_NAME':
{
const { displayName } = action.payload;
client.changeDisplayName(displayName);
break;
}
case 'MUTE_MIC':
{
client.muteMic();
break;
}
case 'UNMUTE_MIC':
{
client.unmuteMic();
break;
}
case 'ENABLE_WEBCAM':
{
client.enableWebcam();
break;
}
case 'DISABLE_WEBCAM':
{
client.disableWebcam();
break;
}
case 'CHANGE_WEBCAM':
{
client.changeWebcam();
break;
}
case 'ENABLE_AUDIO_ONLY':
{
client.enableAudioOnly();
break;
}
case 'DISABLE_AUDIO_ONLY':
{
client.disableAudioOnly();
break;
}
case 'RESTART_ICE':
{
client.restartIce();
break;
}
}
return next(action);
};
};
+222
View File
@@ -0,0 +1,222 @@
export const setRoomUrl = (url) =>
{
return {
type : 'SET_ROOM_URL',
payload : { url }
};
};
export const setRoomState = (state) =>
{
return {
type : 'SET_ROOM_STATE',
payload : { state }
};
};
export const setRoomActiveSpeaker = (peerName) =>
{
return {
type : 'SET_ROOM_ACTIVE_SPEAKER',
payload : { peerName }
};
};
export const setMe = ({ peerName, displayName, displayNameSet, device }) =>
{
return {
type : 'SET_ME',
payload : { peerName, displayName, displayNameSet, device }
};
};
export const setMediaCapabilities = ({ canSendMic, canSendWebcam }) =>
{
return {
type : 'SET_MEDIA_CAPABILITIES',
payload : { canSendMic, canSendWebcam }
};
};
export const setCanChangeWebcam = (flag) =>
{
return {
type : 'SET_CAN_CHANGE_WEBCAM',
payload : flag
};
};
export const setDisplayName = (displayName) =>
{
return {
type : 'SET_DISPLAY_NAME',
payload : { displayName }
};
};
export const setAudioOnlyState = (enabled) =>
{
return {
type : 'SET_AUDIO_ONLY_STATE',
payload : { enabled }
};
};
export const setAudioOnlyInProgress = (flag) =>
{
return {
type : 'SET_AUDIO_ONLY_IN_PROGRESS',
payload : { flag }
};
};
export const setRestartIceInProgress = (flag) =>
{
return {
type : 'SET_RESTART_ICE_IN_PROGRESS',
payload : { flag }
};
};
export const addProducer = (producer) =>
{
return {
type : 'ADD_PRODUCER',
payload : { producer }
};
};
export const removeProducer = (producerId) =>
{
return {
type : 'REMOVE_PRODUCER',
payload : { producerId }
};
};
export const setProducerPaused = (producerId, originator) =>
{
return {
type : 'SET_PRODUCER_PAUSED',
payload : { producerId, originator }
};
};
export const setProducerResumed = (producerId, originator) =>
{
return {
type : 'SET_PRODUCER_RESUMED',
payload : { producerId, originator }
};
};
export const setProducerTrack = (producerId, track) =>
{
return {
type : 'SET_PRODUCER_TRACK',
payload : { producerId, track }
};
};
export const setWebcamInProgress = (flag) =>
{
return {
type : 'SET_WEBCAM_IN_PROGRESS',
payload : { flag }
};
};
export const addPeer = (peer) =>
{
return {
type : 'ADD_PEER',
payload : { peer }
};
};
export const removePeer = (peerName) =>
{
return {
type : 'REMOVE_PEER',
payload : { peerName }
};
};
export const setPeerDisplayName = (displayName, peerName) =>
{
return {
type : 'SET_PEER_DISPLAY_NAME',
payload : { displayName, peerName }
};
};
export const addConsumer = (consumer, peerName) =>
{
return {
type : 'ADD_CONSUMER',
payload : { consumer, peerName }
};
};
export const removeConsumer = (consumerId, peerName) =>
{
return {
type : 'REMOVE_CONSUMER',
payload : { consumerId, peerName }
};
};
export const setConsumerPaused = (consumerId, originator) =>
{
return {
type : 'SET_CONSUMER_PAUSED',
payload : { consumerId, originator }
};
};
export const setConsumerResumed = (consumerId, originator) =>
{
return {
type : 'SET_CONSUMER_RESUMED',
payload : { consumerId, originator }
};
};
export const setConsumerEffectiveProfile = (consumerId, profile) =>
{
return {
type : 'SET_CONSUMER_EFFECTIVE_PROFILE',
payload : { consumerId, profile }
};
};
export const setConsumerTrack = (consumerId, track) =>
{
return {
type : 'SET_CONSUMER_TRACK',
payload : { consumerId, track }
};
};
export const addNotification = (notification) =>
{
return {
type : 'ADD_NOTIFICATION',
payload : { notification }
};
};
export const removeNotification = (notificationId) =>
{
return {
type : 'REMOVE_NOTIFICATION',
payload : { notificationId }
};
};
export const removeAllNotifications = () =>
{
return {
type : 'REMOVE_ALL_NOTIFICATIONS'
};
};
+3 -8
View File
@@ -1,12 +1,7 @@
'use strict';
const config = require('../config');
export function getProtooUrl(peerId, roomId)
export function getProtooUrl(peerName, roomId)
{
let hostname = window.location.hostname;
let port = config.protoo.listenPort;
let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`;
const hostname = window.location.hostname;
const url = `wss://${hostname}:3443/?peerName=${peerName}&roomId=${roomId}`;
return url;
}
+4 -59
View File
@@ -1,75 +1,20 @@
'use strict';
import browser from 'bowser';
import randomNumberLib from 'random-number';
import Logger from './Logger';
global.BROWSER = browser;
const logger = new Logger('utils');
const randomNumberGenerator = randomNumberLib.generator(
{
min : 10000000,
max : 99999999,
integer : true
});
let mediaQueryDetectorElem;
export function initialize()
{
logger.debug('initialize()');
// Media query detector stuff
mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector');
// Media query detector stuff.
mediaQueryDetectorElem =
document.getElementById('mediasoup-demo-app-media-query-detector');
return Promise.resolve();
}
export function isDesktop()
{
return !!mediaQueryDetectorElem.offsetParent;
return Boolean(mediaQueryDetectorElem.offsetParent);
}
export function isMobile()
{
return !mediaQueryDetectorElem.offsetParent;
}
export function isPlanB()
{
if (browser.chrome || browser.chromium || browser.opera || browser.safari || browser.msedge)
return true;
else
return false;
}
/**
* Unfortunately Edge produces rtpSender.send() to fail when receiving media
* from others and removing/adding a local track.
*/
export function canChangeResolution()
{
if (browser.msedge)
return false;
return true;
}
export function randomNumber()
{
return randomNumberGenerator();
}
export function closeMediaStream(stream)
{
if (!stream)
return;
let tracks = stream.getTracks();
for (let i=0, len=tracks.length; i < len; i++)
{
tracks[i].stop();
}
}