Make demo public

This commit is contained in:
Iñaki Baz Castillo
2017-04-23 14:54:30 +02:00
commit f1658f1b3c
54 changed files with 5241 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
'use strict';
import React from 'react';
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 : React.PropTypes.string.isRequired,
roomId : React.PropTypes.string.isRequired
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
import React from 'react';
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 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={`state-${props.connectionState}`}>
{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 : React.PropTypes.string.isRequired,
stream : React.PropTypes.object,
resolution : React.PropTypes.string,
multipleWebcams : React.PropTypes.bool.isRequired,
webcamType : React.PropTypes.string,
connectionState : React.PropTypes.string,
onMicMute : React.PropTypes.func.isRequired,
onWebcamToggle : React.PropTypes.func.isRequired,
onWebcamChange : React.PropTypes.func.isRequired,
onResolutionChange : React.PropTypes.func.isRequired
};
+151
View File
@@ -0,0 +1,151 @@
'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);
}
}
+101
View File
@@ -0,0 +1,101 @@
'use strict';
import React from 'react';
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'); // eslint-disable-line no-unused-vars
export default class RemoteVideo extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
audioMuted : false
};
}
render()
{
let props = this.props;
let state = this.state;
let hasVideo = !!props.stream.getVideoTracks()[0];
global.SS = props.stream;
return (
<div
data-component='RemoteVideo'
className={classnames({ fullsize: !!props.fullsize })}
>
<Video
stream={props.stream}
muted={state.audioMuted}
/>
<div className='controls'>
<IconButton
className='control'
onClick={this.handleClickMuteAudio.bind(this)}
>
<VolumeOffIcon
color={!state.audioMuted ? '#fff' : '#ff0000'}
/>
</IconButton>
<IconButton
className='control'
onClick={this.handleClickDisableVideo.bind(this)}
>
<VideoOffIcon
color={hasVideo ? '#fff' : '#ff8a00'}
/>
</IconButton>
</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 stream = this.props.stream;
let msid = stream.id;
let hasVideo = !!stream.getVideoTracks()[0];
if (hasVideo)
this.props.onDisableVideo(msid);
else
this.props.onEnableVideo(msid);
}
}
RemoteVideo.propTypes =
{
peer : React.PropTypes.object.isRequired,
stream : React.PropTypes.object.isRequired,
fullsize : React.PropTypes.bool,
onDisableVideo : React.PropTypes.func.isRequired,
onEnableVideo : React.PropTypes.func.isRequired
};
+487
View File
@@ -0,0 +1,487 @@
'use strict';
import React from 'react';
import ClipboardButton from 'react-clipboard.js';
import TransitionAppear from './TransitionAppear';
import LocalVideo from './LocalVideo';
import RemoteVideo from './RemoteVideo';
import Stats from './Stats';
import Logger from '../Logger';
import utils from '../utils';
import Client from '../Client';
const logger = new Logger('Room');
const STATS_INTERVAL = 1000;
export default class Room extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
peers : {},
localStream : null,
localVideoResolution : null, // qvga / vga / hd / fullhd.
multipleWebcams : false,
webcamType : null,
connectionState : null,
remoteStreams : {},
showStats : false,
stats : null
};
// Mounted flag
this._mounted = false;
// Client instance
this._client = null;
// Timer to retrieve RTC stats.
this._statsTimer = null;
}
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) =>
{
let stream = state.remoteStreams[msid];
let peer;
for (let peerId of Object.keys(state.peers))
{
peer = state.peers[peerId];
if (peer.msids.indexOf(msid) !== -1)
break;
}
if (!peer)
return;
return (
<TransitionAppear key={msid} duration={500}>
<RemoteVideo
peer={peer}
stream={stream}
fullsize={numPeers === 1}
onDisableVideo={this.handleDisableRemoteVideo.bind(this)}
onEnableVideo={this.handleEnableRemoteVideo.bind(this)}
/>
</TransitionAppear>
);
})
}
</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}
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()');
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);
this._client.disableRemoteVideo(msid);
}
handleEnableRemoteVideo(msid)
{
logger.debug('handleEnableRemoteVideo() [msid:"%s"]', msid);
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)
if (utils.isDesktop())
{
this.setState({ showStats: true });
setTimeout(() =>
{
this._startStats();
}, STATS_INTERVAL / 2);
}
});
this._client.on('close', (error) =>
{
// Clear remote streams (for reconnections).
this.setState({ remoteStreams: {} });
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;
delete peers[peer.id];
this.setState({ peers });
});
this._client.on('connectionstate', (state) =>
{
this.setState({ connectionState: state });
});
this._client.on('addstream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
remoteStreams[stream.id] = stream;
this.setState({ remoteStreams });
});
this._client.on('removestream', (stream) =>
{
let remoteStreams = this.state.remoteStreams;
delete remoteStreams[stream.id];
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();
});
}
_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);
}
}
Room.propTypes =
{
peerId : React.PropTypes.string.isRequired,
roomId : React.PropTypes.string.isRequired,
onNotify : React.PropTypes.func.isRequired,
onHideNotification : React.PropTypes.func.isRequired
};
+497
View File
@@ -0,0 +1,497 @@
'use strict';
import React from 'react';
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)
{
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
{
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':
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())
{
// TODO: REMOVE
global.STATS = stats;
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
}
});
}
}
Stats.propTypes =
{
stats : React.PropTypes.object.isRequired,
onClose : React.PropTypes.func.isRequired
};
+59
View File
@@ -0,0 +1,59 @@
'use strict';
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
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 (
<ReactCSSTransitionGroup
component={FakeTransitionWrapper}
transitionName='transition'
transitionAppear={!!duration}
transitionAppearTimeout={duration}
transitionEnter={false}
transitionLeave={false}
>
{this.props.children}
</ReactCSSTransitionGroup>
);
}
}
TransitionAppear.propTypes =
{
children : React.PropTypes.any,
duration : React.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 : React.PropTypes.any
};
+233
View File
@@ -0,0 +1,233 @@
'use strict';
import React from 'react';
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 })}
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) =>
{
return track.id;
})
.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 : React.PropTypes.object.isRequired,
resolution : React.PropTypes.string,
muted : React.PropTypes.bool,
mirror : React.PropTypes.bool,
onResolutionChange : React.PropTypes.func
};
+16
View File
@@ -0,0 +1,16 @@
'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;