diff --git a/app/assets/styles/blink/_AudioCallBox.scss b/app/assets/styles/blink/_AudioCallBox.scss
index 0b1c171..b39a694 100644
--- a/app/assets/styles/blink/_AudioCallBox.scss
+++ b/app/assets/styles/blink/_AudioCallBox.scss
@@ -1,96 +1,101 @@
.container {
flex: 1;
}
.userIconContainer {
padding-top: 20px;
margin: 0 auto;
}
+.statsContainer {
+ padding-top: 0px;
+ margin: 0 auto;
+ width: 50%;
+}
+
.tabletUserIconContainer {
padding-top: 60px;
margin: 0 auto;
}
.appbarContainer {
background-color: rgba(34,34,34,.7);
z-index: 1;
}
.portraitButtonContainer {
justify-self: flex-end;
flex-direction: row;
margin: 0 auto;
margin-top:auto;
bottom: 20;
margin-bottom: 50px;
}
.tabletPortraitButtonContainer {
justify-self: flex-end;
flex-direction: row;
margin: 0 auto;
margin-top:auto;
bottom: 60;
margin-bottom: 40px;
}
.landscapeButtonContainer {
justify-self: flex-end;
flex-direction: row;
margin: 0 auto;
margin-top:auto;
bottom: 10;
margin-bottom: 0px;
}
.tabletLandscapeButtonContainer {
justify-self: flex-end;
flex-direction: row;
margin: 0 auto;
margin-top:auto;
bottom: 60;
margin-bottom: 0px;
}
.activity {
margin-top: 30px;
}
.button {
background-color: white;
margin: 10px;
padding-top: 5px;
padding-left: 0px;
}
.iosButton {
background-color: rgba(#F9F9F9, .7);
margin: 10px;
padding-top: 5px;
}
.androidButton {
background-color: rgba(#F9F9F9, .7);
margin: 10px;
padding-top: 1px;
}
.hangupButton {
background-color: rgba(#a94442, .8);
}
.displayName {
padding-top: 10px;
font-size: 30px;
text-align: center;
color: white;
}
.uri {
padding: 0px;
font-size: 18px;
text-align: center;
color: white;
}
-
diff --git a/app/components/AudioCallBox.js b/app/components/AudioCallBox.js
index fd7dce1..6b24375 100644
--- a/app/components/AudioCallBox.js
+++ b/app/components/AudioCallBox.js
@@ -1,297 +1,334 @@
import React, { Component } from 'react';
import { View, Platform } from 'react-native';
import { IconButton, Dialog, Text, ActivityIndicator, Colors } from 'react-native-paper';
import PropTypes from 'prop-types';
import autoBind from 'auto-bind';
import EscalateConferenceModal from './EscalateConferenceModal';
import CallOverlay from './CallOverlay';
import DTMFModal from './DTMFModal';
import UserIcon from './UserIcon';
import styles from '../assets/styles/blink/_AudioCallBox.scss';
import utils from '../utils';
+import TrafficStats from './BarChart';
+
+
function toTitleCase(str) {
return str.replace(
/\w\S*/g,
function(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
}
);
}
class AudioCallBox extends Component {
constructor(props) {
super(props);
autoBind(this);
this.state = {
remoteUri : this.props.remoteUri,
remoteDisplayName : this.props.remoteDisplayName,
photo : this.props.photo,
active : false,
audioMuted : this.props.muted,
showDtmfModal : false,
showEscalateConferenceModal : false,
call : this.props.call,
- reconnectingCall : this.props.reconnectingCall
+ reconnectingCall : this.props.reconnectingCall,
+ bandwidth : this.props.bandwidth,
+ packetLossQueue : [],
+ audioBandwidthQueue : [],
+ latencyQueue : []
};
this.remoteAudio = React.createRef();
this.userHangup = false;
}
componentDidMount() {
// This component is used both for as 'local media' and as the in-call component.
// Thus, if the call is not null it means we are beyond the 'local media' phase
// so don't call the mediaPlaying prop.
if (this.state.call != null) {
switch (this.state.call.state) {
case 'established':
this.attachStream(this.state.call);
break;
case 'incoming':
this.props.mediaPlaying();
// fall through
default:
this.state.call.on('stateChanged', this.callStateChanged);
break;
}
} else {
this.props.mediaPlaying();
}
}
componentWillUnmount() {
if (this.state.call != null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
}
//getDerivedStateFromProps(nextProps, state) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.call !== null && nextProps.call !== this.state.call) {
if (nextProps.call.state === 'established') {
this.attachStream(nextProps.call);
this.setState({reconnectingCall: false});
} else if (nextProps.call.state === 'incoming') {
this.props.mediaPlaying();
}
nextProps.call.on('stateChanged', this.callStateChanged);
if (this.state.call !== null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
this.setState({call: nextProps.call});
}
if (nextProps.reconnectingCall != this.state.reconnectingCall) {
this.setState({reconnectingCall: nextProps.reconnectingCall});
}
if (nextProps.hasOwnProperty('muted')) {
this.setState({audioMuted: nextProps.muted});
}
+ if (nextProps.hasOwnProperty('bandwidth')) {
+ this.setState({bandwidth: nextProps.bandwidth});
+ }
+
+ if (nextProps.hasOwnProperty('packetLossQueue')) {
+ this.setState({packetLossQueue: nextProps.packetLossQueue});
+ }
+
+ if (nextProps.hasOwnProperty('audioBandwidthQueue')) {
+ this.setState({audioBandwidthQueue: nextProps.audioBandwidthQueue});
+ }
+
+ if (nextProps.hasOwnProperty('latencyQueue')) {
+ this.setState({latencyQueue: nextProps.latencyQueue});
+ }
+
this.setState({remoteUri: nextProps.remoteUri,
remoteDisplayName: nextProps.remoteDisplayName,
photo: nextProps.photo
});
}
componentWillUnmount() {
if (this.state.call != null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
clearTimeout(this.callTimer);
}
callStateChanged(oldState, newState, data) {
if (newState === 'established') {
this.attachStream(this.state.call);
this.setState({reconnectingCall: false});
}
}
attachStream(call) {
this.setState({stream: call.getRemoteStreams()[0]}); //we dont use it anywhere though as audio gets automatically piped
}
escalateToConference(participants) {
this.props.escalateToConference(participants);
}
hangupCall(event) {
event.preventDefault();
this.props.hangupCall('user_hangup_call');
this.userHangup = true;
}
cancelCall(event) {
event.preventDefault();
this.props.hangupCall('user_cancelled_call');
}
muteAudio(event) {
event.preventDefault();
this.props.toggleMute(this.props.call.id, !this.state.audioMuted);
}
showDtmfModal() {
this.setState({showDtmfModal: true});
}
hideDtmfModal() {
this.setState({showDtmfModal: false});
}
toggleEscalateConferenceModal() {
this.setState({
showEscalateConferenceModal: !this.state.showEscalateConferenceModal
});
}
render() {
let buttonContainerClass;
let userIconContainerClass;
let remoteIdentity = {uri: this.state.remoteUri || '',
displayName: this.state.remoteDisplayName || '',
photo: this.state.photo
};
let displayName = this.state.remoteUri ? toTitleCase(this.state.remoteUri.split('@')[0]) : '';
if (this.state.remoteDisplayName && this.state.remoteUri !== this.state.remoteDisplayName) {
displayName = this.state.remoteDisplayName;
}
if (this.props.isTablet) {
buttonContainerClass = this.props.orientation === 'landscape' ? styles.tabletLandscapeButtonContainer : styles.tabletPortraitButtonContainer;
userIconContainerClass = styles.tabletUserIconContainer;
} else {
buttonContainerClass = this.props.orientation === 'landscape' ? styles.landscapeButtonContainer : styles.portraitButtonContainer;
userIconContainerClass = styles.userIconContainer;
}
const buttonSize = this.props.isTablet ? 40 : 34;
const buttonClass = (Platform.OS === 'ios') ? styles.iosButton : styles.androidButton;
return (
{displayName}
{this.state.remoteUri}
{this.props.orientation !== 'landscape' && this.state.reconnectingCall ?
:
null
}
+
+
{this.state.call && ((this.state.call.state === 'accepted' || this.state.call.state === 'established') && !this.state.reconnectingCall) ?
:
}
);
}
}
AudioCallBox.propTypes = {
remoteUri : PropTypes.string,
remoteDisplayName : PropTypes.string,
photo : PropTypes.string,
call : PropTypes.object,
connection : PropTypes.object,
accountId : PropTypes.string,
escalateToConference : PropTypes.func,
+ bandwidth : PropTypes.string,
hangupCall : PropTypes.func,
mediaPlaying : PropTypes.func,
callKeepSendDtmf : PropTypes.func,
toggleMute : PropTypes.func,
toggleSpeakerPhone : PropTypes.func,
speakerPhoneEnabled : PropTypes.bool,
orientation : PropTypes.string,
isTablet : PropTypes.bool,
reconnectingCall : PropTypes.bool,
- muted : PropTypes.bool
-
+ muted : PropTypes.bool,
+ packetLossQueue : PropTypes.array,
+ videoBandwidthQueue : PropTypes.array,
+ audioBandwidthQueue : PropTypes.array,
+ latencyQueue : PropTypes.array
};
export default AudioCallBox;
diff --git a/app/components/Call.js b/app/components/Call.js
index 926bcae..be5448b 100644
--- a/app/components/Call.js
+++ b/app/components/Call.js
@@ -1,551 +1,917 @@
import React, { Component } from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types';
import assert from 'assert';
import debug from 'react-native-debug';
import autoBind from 'auto-bind';
import uuid from 'react-native-uuid';
import AudioCallBox from './AudioCallBox';
import LocalMedia from './LocalMedia';
import VideoBox from './VideoBox';
import config from '../config';
import utils from '../utils';
+function randomIntFromInterval(min,max)
+{
+ return Math.floor(Math.random()*(max-min+1)+min);
+}
+
+function FixedQueue( size, initialValues ){
+
+ // If there are no initial arguments, default it to
+ // an empty value so we can call the constructor in
+ // a uniform way.
+ initialValues = (initialValues || []);
+
+ // Create the fixed queue array value.
+ var queue = Array.apply( null, initialValues );
+
+ // Store the fixed size in the queue.
+ queue.fixedSize = size;
+
+ // Add the class methods to the queue. Some of these have
+ // to override the native Array methods in order to make
+ // sure the queue lenght is maintained.
+ queue.push = FixedQueue.push;
+ queue.splice = FixedQueue.splice;
+ queue.unshift = FixedQueue.unshift;
+
+ // Trim any initial excess from the queue.
+ FixedQueue.trimTail.call( queue );
+
+ // Return the new queue.
+ return( queue );
+
+}
+
+
+// I trim the queue down to the appropriate size, removing
+// items from the beginning of the internal array.
+FixedQueue.trimHead = function(){
+
+ // Check to see if any trimming needs to be performed.
+ if (this.length <= this.fixedSize){
+
+ // No trimming, return out.
+ return;
+
+ }
+
+ // Trim whatever is beyond the fixed size.
+ Array.prototype.splice.call(
+ this,
+ 0,
+ (this.length - this.fixedSize)
+ );
+
+};
+
+
+// I trim the queue down to the appropriate size, removing
+// items from the end of the internal array.
+FixedQueue.trimTail = function(){
+
+ // Check to see if any trimming needs to be performed.
+ if (this.length <= this.fixedSize){
+
+ // No trimming, return out.
+ return;
+
+ }
+
+ // Trim whatever is beyond the fixed size.
+ Array.prototype.splice.call(
+ this,
+ this.fixedSize,
+ (this.length - this.fixedSize)
+ );
+
+};
+
+
+// I synthesize wrapper methods that call the native Array
+// methods followed by a trimming method.
+FixedQueue.wrapMethod = function( methodName, trimMethod ){
+
+ // Create a wrapper that calls the given method.
+ var wrapper = function(){
+
+ // Get the native Array method.
+ var method = Array.prototype[ methodName ];
+
+ // Call the native method first.
+ var result = method.apply( this, arguments );
+
+ // Trim the queue now that it's been augmented.
+ trimMethod.call( this );
+
+ // Return the original value.
+ return( result );
+
+ };
+
+ // Return the wrapper method.
+ return( wrapper );
+
+};
+
+
+// Wrap the native methods.
+FixedQueue.push = FixedQueue.wrapMethod(
+ "push",
+ FixedQueue.trimHead
+);
+
+FixedQueue.splice = FixedQueue.wrapMethod(
+ "splice",
+ FixedQueue.trimTail
+);
+
+FixedQueue.unshift = FixedQueue.wrapMethod(
+ "unshift",
+ FixedQueue.trimTail
+);
+
+
class Call extends Component {
constructor(props) {
super(props);
autoBind(this);
+ this.samples = 30;
+ this.sampleInterval = 3;
+
this.defaultWaitInterval = 60; // until we can connect or reconnect
this.waitCounter = 0;
this.waitInterval = this.defaultWaitInterval;
+ this.videoBytesSent = 0;
+ this.audioBytesSent = 0;
+
+ this.videoBytesReceived = 0;
+ this.audioBytesReceived = 0;
+
+ this.packetLoss = 0;
+
+ this.packetLossQueue = FixedQueue(this.samples);
+ this.latencyQueue = FixedQueue(this.samples);
+ this.audioBandwidthQueue = FixedQueue(this.samples);
+ this.videoBandwidthQueue = FixedQueue(this.samples);
+
+ this.mediaLost = false;
+
let callUUID;
let remoteUri = '';
let remoteDisplayName = '';
let callState = null;
let direction = null;
let callEnded = false;
this.mediaIsPlaying = false;
this.ended = false;
this.answering = false;
if (this.props.call) {
// If current call is available on mount we must have incoming
this.props.call.on('stateChanged', this.callStateChanged);
remoteUri = this.props.call.remoteIdentity.uri;
remoteDisplayName = this.props.call.remoteIdentity.displayName || this.props.call.remoteIdentity.uri;
direction = this.props.call.direction;
callUUID = this.props.call.id;
} else {
remoteUri = this.props.targetUri;
remoteDisplayName = this.props.targetUri;
callUUID = this.props.callUUID;
direction = callUUID ? 'outgoing' : 'incoming';
}
if (this.props.connection) {
this.props.connection.on('stateChanged', this.connectionStateChanged);
}
let audioOnly = false;
if (this.props.localMedia && this.props.localMedia.getVideoTracks().length === 0) {
audioOnly = true;
}
this.state = {
call: this.props.call,
targetUri: this.props.targetUri,
audioOnly: audioOnly,
boo: false,
remoteUri: remoteUri,
remoteDisplayName: remoteDisplayName,
localMedia: this.props.localMedia,
connection: this.props.connection,
accountId: this.props.account ? this.props.account.id : null,
callState: callState,
direction: direction,
callUUID: callUUID,
- reconnectingCall: this.props.reconnectingCall
+ reconnectingCall: this.props.reconnectingCall,
+ bandwidth: '',
+ packetLossQueue: [],
+ audioBandwidthQueue: [],
+ videoBandwidthQueue: [],
+ latencyQueue: []
}
+
+ this.statisticsTimer = setInterval(() => {
+ this.getConnectionStats();
+ }, this.sampleInterval * 1000);
+
+ this.resetStats();
+
+ }
+
+ resetStats() {
+ this.setState({
+ bandwidth: '',
+ packetLossQueue: [],
+ audioBandwidthQueue: [],
+ videoBandwidthQueue: [],
+ latencyQueue: []
+ });
}
//getDerivedStateFromProps(nextProps, state) {
UNSAFE_componentWillReceiveProps(nextProps) {
// Needed for switching to incoming call while in a call
if (this.ended) {
return;
}
//console.log('Call got props...');
this.setState({connection: nextProps.connection,
accountId: nextProps.account ? nextProps.account.id : null});
if (nextProps.call !== null) {
if (this.state.call !== nextProps.call) {
nextProps.call.on('stateChanged', this.callStateChanged);
this.setState({
call: nextProps.call,
remoteUri: nextProps.call.remoteIdentity.uri,
direction: nextProps.call.direction,
callUUID: nextProps.call.id,
remoteDisplayName: nextProps.call.remoteIdentity.displayName
});
if (nextProps.call.direction === 'incoming') {
this.mediaPlaying();
}
this.lookupContact();
}
} else {
if (nextProps.callUUID !== null && this.state.callUUID !== nextProps.callUUID) {
this.setState({'callUUID': nextProps.callUUID,
'direction': 'outgoing',
'call': null
});
this.startCallWhenReady(nextProps.callUUID);
}
}
if (nextProps.reconnectingCall !== this.state.reconnectingCall) {
this.setState({reconnectingCall: nextProps.reconnectingCall});
}
if (nextProps.targetUri !== this.state.targetUri && this.state.direction === 'outgoing') {
this.setState({targetUri: nextProps.targetUri});
}
this.setState({registrationState: nextProps.registrationState});
if (nextProps.localMedia !== null && nextProps.localMedia !== this.state.localMedia) {
let audioOnly = false;
if (nextProps.localMedia.getVideoTracks().length === 0) {
audioOnly = true;
}
this.setState({localMedia: nextProps.localMedia,
audioOnly: audioOnly});
this.mediaPlaying(nextProps.localMedia);
}
}
+ getConnectionStats() {
+ //console.log('getConnectionStats');
+ let speed = 0;
+ let diff = 0;
+
+ let delay = 0;
+
+ let audioPackets = 0;
+ let videoPackets = 0;
+
+ let audioPacketsLost = 0;
+ let videoPacketsLost = 0;
+
+ let audioPacketLoss = 0;
+ let videoPacketLoss = 0;
+
+ let bandwidthUpload = 0;
+ let bandwidthDownload = 0;
+
+ let mediaType;
+ let foundVideo = false;
+
+ if (!this.state.call || !this.state.call._pc) {
+ this.resetStats();
+ return;
+ }
+
+ this.state.call._pc.getStats(null).then(stats => {
+ stats.forEach(report => {
+
+ if (report.type === "ssrc") {
+ report.values.forEach(object => { if (object.mediaType) {
+ mediaType = object.mediaType;
+ }
+ });
+
+ report.values.forEach(object => {
+ if (object.bytesReceived) {
+ const bytesReceived = Math.floor(object.bytesReceived);
+ if (mediaType === 'audio') {
+ if (this.audioBytesReceived > 0 && this.audioBytesReceived < bytesReceived) {
+ diff = bytesReceived - this.audioBytesReceived;
+ diff = bytesReceived - this.audioBytesReceived;
+ speed = Math.floor(diff / this.sampleInterval * 8 / 1000);
+ //console.log('Audio bandwidth received', speed, 'kbit/s');
+ bandwidthDownload = bandwidthDownload + speed;
+ if (this.audioBandwidthQueue.length < this.samples) {
+ var n = this.samples;
+ while (n > 0) {
+ this.audioBandwidthQueue.push(0);
+ n = n - 1;
+ }
+ }
+
+ this.audioBandwidthQueue.push(speed);
+ }
+ this.audioBytesReceived = bytesReceived;
+ } else if (mediaType === 'video') {
+ foundVideo = true;
+ if (this.videoBytesReceived > 0 && this.videoBytesReceived < bytesReceived) {
+ diff = bytesReceived - this.videoBytesReceived;
+ speed = Math.floor(diff / this.sampleInterval * 8 / 1000);
+ //console.log('Video bandwidth received', speed, 'kbit/s');
+ bandwidthDownload = bandwidthDownload + speed;
+ if (this.videoBandwidthQueue.length < this.samples) {
+ var n = this.samples;
+ while (n > 0) {
+ this.videoBandwidthQueue.push(0);
+ n = n - 1;
+ }
+ }
+ this.videoBandwidthQueue.push(speed)
+ }
+ this.videoBytesReceived = bytesReceived;
+ }
+ } else if (object.bytesSent) {
+ const bytesSent = Math.floor(object.bytesSent);
+ if (mediaType === 'audio') {
+ if (this.audioBytesSent > 0 && bytesSent > this.audioBytesSent) {
+ const diff = bytesSent - this.audioBytesSent;
+ const speed = Math.floor(diff / this.sampleInterval * 8 / 1000);
+ bandwidthUpload = bandwidthUpload + speed;
+ //console.log('Audio bandwidth sent', speed, 'kbit/s');
+ }
+ this.audioBytesSent = bytesSent;
+ } else if (mediaType === 'video') {
+ foundVideo = true;
+ if (this.videoBytesSent > 0 && bytesSent > this.videoBytesSent) {
+ const diff = bytesSent - this.videoBytesSent;
+ const speed = Math.floor(diff / this.sampleInterval * 8 / 1000);
+ bandwidthUpload = bandwidthUpload + speed;
+ //console.log('Video bandwidth sent', speed, 'kbit/s');
+ }
+ this.videoBytesSent = bytesSent;
+ }
+
+ } else if (object.packetsLost) {
+ if (mediaType === 'audio') {
+ audioPackets = audioPackets + Math.floor(object.packetsLost);
+ audioPacketsLost = audioPacketsLost + Math.floor(object.packetsLost);
+ } else if (mediaType === 'video') {
+ videoPackets = videoPackets + Math.floor(object.packetsLost);
+ videoPacketsLost = videoPacketsLost + Math.floor(object.packetsLost);
+ }
+ } else if (object.packetsReceived) {
+ if (mediaType === 'audio') {
+ audioPackets = audioPackets + Math.floor(object.packetsReceived);
+ } else if (mediaType === 'video') {
+ videoPackets = videoPackets + Math.floor(object.packetsReceived);
+ }
+ } else if (object.googCurrentDelayMs) {
+ delay = object.googCurrentDelayMs;
+ }
+ //console.log(object);
+ });
+
+ }});
+
+ // packet loss
+
+ videoPacketLoss = 0;
+ if (videoPackets > 0) {
+ videoPacketLoss = Math.floor(videoPacketsLost / videoPackets * 100);
+ if (videoPacketLoss > 0) {
+ console.log('Video packet loss', videoPacketLoss, '%');
+ }
+ }
+
+ audioPacketLoss = 0;
+ if (audioPackets > 0) {
+ audioPacketLoss = Math.floor(audioPacketsLost / audioPackets * 100);
+ if (audioPacketLoss > 0) {
+ console.log('Audio packet loss', videoPacketLoss, '%');
+ }
+ }
+
+ this.packetLoss = videoPacketLoss > audioPacketLoss ? videoPacketLoss : audioPacketLoss;
+
+ //this.packetLoss = randomIntFromInterval(2, 10);
+
+ if (this.packetLossQueue.length < this.samples) {
+ var n = this.samples;
+ while (n > 0) {
+ this.packetLossQueue.push(0);
+ n = n - 1;
+ }
+ }
+
+ if (this.latencyQueue.length < this.samples) {
+ var n = this.samples;
+ while (n > 0) {
+ this.latencyQueue.push(0);
+ n = n - 1;
+ }
+ }
+
+ this.latencyQueue.push(Math.ceil(delay));
+
+ this.packetLossQueue.push(this.packetLoss);
+
+ this.audioPacketLoss = audioPacketLoss;
+ this.videoPacketLoss = videoPacketLoss;
+
+ let bandwidth;
+ let suffix = ' kbit/s';
+
+ if (foundVideo) {
+ suffix = ' Mbit/s';
+ bandwidthUpload = Math.ceil(bandwidthUpload / 1000 * 100) / 100;
+ bandwidthDownload = Math.ceil(bandwidthDownload / 1000 * 100) / 100;
+ }
+
+ if (bandwidthDownload > 0 && bandwidthUpload > 0) {
+ bandwidth = '⇣' + bandwidthDownload + ' ⇡' + bandwidthUpload;
+ } else if (bandwidthDownload > 0) {
+ bandwidth = '⇣' + bandwidthDownload;
+ } else if (bandwidthUpload > 0) {
+ bandwidth = '⇡' + this.bandwidthUpload;
+ }
+
+ if (bandwidth) {
+ bandwidth = bandwidth + suffix;
+ }
+
+ if (this.packetLoss > 2) {
+ bandwidth = bandwidth + ' - ' + Math.ceil(this.packetLoss) + '% loss';
+ }
+
+ this.setState({packetLossQueue: this.packetLossQueue,
+ latencyQueue: this.latencyQueue,
+ videoBandwidthQueue: this.videoBandwidthQueue,
+ audioBandwidthQueue: this.audioBandwidthQueue,
+ bandwidth: bandwidth
+ });
+ });
+ };
+
mediaPlaying(localMedia) {
if (this.state.direction === 'incoming') {
const media = localMedia ? localMedia : this.state.localMedia;
this.answerCall(media);
} else {
this.mediaIsPlaying = true;
}
}
answerCall(localMedia) {
const media = localMedia ? localMedia : this.state.localMedia;
if (this.state.call && this.state.call.state === 'incoming' && media) {
let options = {pcConfig: {iceServers: config.iceServers}};
options.localStream = media;
if (!this.answering) {
this.answering = true;
const connectionState = this.state.connection.state ? this.state.connection.state : null;
utils.timestampedLog('Call: answering call in connection state', connectionState);
this.state.call.answer(options);
} else {
utils.timestampedLog('Call: answering call in progress...');
}
} else {
if (!media) {
utils.timestampedLog('Call: waiting for local media');
}
if (!this.state.call) {
utils.timestampedLog('Call: waiting for incoming call data');
}
}
}
componentDidMount() {
this.lookupContact();
if (this.state.direction === 'outgoing' && this.state.callUUID) {
this.startCallWhenReady(this.state.callUUID);
}
}
componentWillUnmount() {
this.ended = true;
this.answering = false;
if (this.state.call) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
if (this.state.connection) {
this.state.connection.removeListener('stateChanged', this.connectionStateChanged);
}
}
lookupContact() {
let photo = null;
let remoteUri = this.state.remoteUri || '';
let remoteDisplayName = this.state.remoteDisplayName || '';
if (!remoteUri) {
return;
}
if (remoteUri.indexOf('3333@') > -1) {
remoteDisplayName = 'Video Test';
} else if (remoteUri.indexOf('4444@') > -1) {
remoteDisplayName = 'Echo Test';
} else if (this.props.contacts) {
let username = remoteUri.split('@')[0];
let isPhoneNumber = username.match(/^(\+|0)(\d+)$/);
if (isPhoneNumber) {
var contact_obj = this.findObjectByKey(this.props.contacts, 'remoteParty', username);
} else {
var contact_obj = this.findObjectByKey(this.props.contacts, 'remoteParty', remoteUri);
}
if (contact_obj) {
remoteDisplayName = contact_obj.displayName;
photo = contact_obj.photo;
if (isPhoneNumber) {
remoteUri = username;
}
} else {
if (isPhoneNumber) {
remoteUri = username;
remoteDisplayName = username;
}
}
}
this.setState({remoteDisplayName: remoteDisplayName,
remoteUri: remoteUri,
photo: photo
});
}
callStateChanged(oldState, newState, data) {
//console.log('Call: callStateChanged', oldState, '->', newState);
if (this.ended) {
return;
}
let remoteHasNoVideoTracks;
let remoteIsRecvOnly;
let remoteIsInactive;
let remoteStreams;
this.answering = false;
if (newState === 'established') {
this.setState({reconnectingCall: false});
const currentCall = this.state.call;
if (currentCall) {
remoteStreams = currentCall.getRemoteStreams();
if (remoteStreams) {
if (remoteStreams.length > 0) {
const remotestream = remoteStreams[0];
remoteHasNoVideoTracks = remotestream.getVideoTracks().length === 0;
remoteIsRecvOnly = currentCall.remoteMediaDirections.video[0] === 'recvonly';
remoteIsInactive = currentCall.remoteMediaDirections.video[0] === 'inactive';
}
}
}
if (remoteStreams && (remoteHasNoVideoTracks || remoteIsRecvOnly || remoteIsInactive) && !this.state.audioOnly) {
//console.log('Media type changed to audio');
// Stop local video
if (this.state.localMedia.getVideoTracks().length !== 0) {
currentCall.getLocalStreams()[0].getVideoTracks()[0].stop();
}
this.setState({audioOnly: true});
} else {
this.forceUpdate();
}
} else if (newState === 'accepted') {
// Switch if we have audioOnly and local videotracks. This means
// the call object switched and we are transitioning to an
// incoming call.
if (this.state.audioOnly && this.state.localMedia && this.state.localMedia.getVideoTracks().length !== 0) {
//console.log('Media type changed to video on accepted');
this.setState({audioOnly: false});
}
}
this.forceUpdate();
}
connectionStateChanged(oldState, newState) {
switch (newState) {
case 'closed':
break;
case 'ready':
break;
case 'disconnected':
if (oldState === 'ready' && this.state.direction === 'outgoing') {
utils.timestampedLog('Call: reconnecting the call...');
this.waitInterval = this.defaultWaitInterval;
}
break;
default:
break;
}
}
findObjectByKey(array, key, value) {
for (var i = 0; i < array.length; i++) {
if (array[i][key] === value) {
return array[i];
}
}
return null;
}
canConnect() {
if (!this.state.connection) {
console.log('Call: no connection yet');
return false;
}
if (this.state.connection.state !== 'ready') {
console.log('Call: connection is not ready');
return false;
}
if (this.props.registrationState !== 'registered') {
console.log('Call: account not ready yet');
return false;
}
if (!this.mediaIsPlaying) {
console.log('Call: media is not playing');
return false;
}
return true;
}
async startCallWhenReady(callUUID) {
utils.timestampedLog('Call: start call', callUUID, 'when ready to', this.state.targetUri);
this.waitCounter = 0;
let diff = 0;
while (this.waitCounter < this.waitInterval) {
if (this.waitCounter === 1) {
utils.timestampedLog('Call: waiting for establishing call', this.waitInterval, 'seconds');
}
if (this.userHangup) {
this.hangupCall('user_cancelled');
return;
}
if (this.ended) {
return;
}
if (this.waitCounter >= this.waitInterval - 1) {
this.hangupCall('timeout');
}
if (!this.canConnect()) {
//utils.timestampedLog('Call: waiting for connection', this.waitInterval - this.waitCounter, 'seconds');
if (this.state.call && this.state.call.id === callUUID && this.state.call.state !== 'terminated') {
return;
}
console.log('Wait', this.waitCounter);
await this._sleep(1000);
} else {
this.waitCounter = 0;
this.start();
return;
}
this.waitCounter++;
}
}
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
start() {
utils.timestampedLog('Call: starting call', this.state.callUUID);
if (this.state.localMedia === null) {
console.log('Call: cannot create new call without local media');
return;
}
let options = {pcConfig: {iceServers: config.iceServers}, id: this.state.callUUID};
options.localStream = this.state.localMedia;
let call = this.props.account.call(this.state.targetUri, options);
if (call) {
call.on('stateChanged', this.callStateChanged);
}
}
hangupCall(reason) {
let callUUID = this.state.call ? this.state.call.id : this.state.callUUID;
this.waitInterval = this.defaultWaitInterval;
if (this.state.call) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
if (this.props.connection) {
this.props.connection.removeListener('stateChanged', this.connectionStateChanged);
}
if (this.waitCounter > 0) {
this.waitCounter = this.waitInterval;
}
this.props.hangupCall(callUUID, reason);
}
render() {
let box = null;
if (this.state.localMedia !== null) {
if (this.state.audioOnly) {
box = (
);
} else {
if (this.state.call !== null && (this.state.call.state === 'established' || (this.state.call.state === 'terminated' && this.state.reconnectingCall))) {
box = (
);
} else {
if (this.state.call && this.state.call.state === 'terminated' && this.state.reconnectingCall) {
//console.log('Skip render local media because we will reconnect');
} else {
box = (
);
}
}
}
} else {
box = (
);
}
return box;
}
}
Call.propTypes = {
targetUri : PropTypes.string,
account : PropTypes.object,
hangupCall : PropTypes.func,
connection : PropTypes.object,
registrationState : PropTypes.string,
call : PropTypes.object,
localMedia : PropTypes.object,
shareScreen : PropTypes.func,
escalateToConference : PropTypes.func,
generatedVideoTrack : PropTypes.bool,
callKeepSendDtmf : PropTypes.func,
toggleMute : PropTypes.func,
toggleSpeakerPhone : PropTypes.func,
speakerPhoneEnabled : PropTypes.bool,
callUUID : PropTypes.string,
contacts : PropTypes.array,
intercomDtmfTone : PropTypes.string,
orientation : PropTypes.string,
isTablet : PropTypes.bool,
reconnectingCall : PropTypes.bool,
muted : PropTypes.bool
};
export default Call;
diff --git a/app/components/CallOverlay.js b/app/components/CallOverlay.js
index 63227be..77bba35 100644
--- a/app/components/CallOverlay.js
+++ b/app/components/CallOverlay.js
@@ -1,208 +1,213 @@
import React from 'react';
import { View, Text } from 'react-native';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import moment from 'moment';
import momentFormat from 'moment-duration-format';
import autoBind from 'auto-bind';
import { Appbar } from 'react-native-paper';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { Colors } from 'react-native-paper';
import styles from '../assets/styles/blink/_AudioCallBox.scss';
function toTitleCase(str) {
return str.replace(
/\w\S*/g,
function(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
}
);
}
class CallOverlay extends React.Component {
constructor(props) {
super(props);
autoBind(this);
this.state = {
call: this.props.call,
callState: this.props.call ? this.props.call.state : null,
direction: this.props.call ? this.props.call.direction: null,
remoteUri: this.props.remoteUri,
remoteDisplayName: this.props.remoteDisplayName,
reconnectingCall: this.props.reconnectingCall
}
this.duration = null;
this.finalDuration = null;
this.timer = null;
this._isMounted = true;
}
componentDidMount() {
if (this.state.call) {
if (this.state.call.state === 'established') {
this.startTimer();
}
this.state.call.on('stateChanged', this.callStateChanged);
this.setState({callState: this.state.call.state});
}
}
//getDerivedStateFromProps(nextProps, state) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (!this._isMounted) {
return;
}
if (nextProps.reconnectingCall != this.state.reconnectingCall) {
this.setState({reconnectingCall: nextProps.reconnectingCall});
}
if (nextProps.call !== null && nextProps.call !== this.state.call) {
nextProps.call.on('stateChanged', this.callStateChanged);
if (this.state.call !== null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
this.setState({call: nextProps.call});
}
this.setState({remoteDisplayName: nextProps.remoteDisplayName, remoteUri: nextProps.remoteUri});
}
componentWillUnmount() {
this._isMounted = false;
if (this.state.call) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
clearTimeout(this.timer);
}
callStateChanged(oldState, newState, data) {
if (newState === 'established' && this._isMounted && !this.props.terminated) {
this.startTimer();
}
if (newState === 'terminated') {
if (this.state.call) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
clearTimeout(this.timer);
this.finalDuration = this.duration;
this.duration = null;
this.timer = null;
}
if (!this._isMounted) {
return;
}
this.setState({callState: newState});
}
startTimer() {
if (this.timer !== null) {
// already armed
return;
}
// TODO: consider using window.requestAnimationFrame
const startTime = new Date();
this.duration = moment.duration(new Date() - startTime).format('hh:mm:ss', {trim: false});
this.timer = setInterval(() => {
this.duration = moment.duration(new Date() - startTime).format('hh:mm:ss', {trim: false});
if (this.props.show) {
this.forceUpdate();
}
}, 1000);
}
render() {
let header = null;
if (this.props.terminated) {
clearTimeout(this.timer);
this.duration = null;
this.timer = null;
}
let displayName = this.state.remoteUri;
if (this.state.remoteDisplayName && this.state.remoteDisplayName !== this.state.remoteUri) {
displayName = this.state.remoteDisplayName;
}
if (this.props.show) {
let callDetail;
if (this.duration) {
callDetail = {this.duration};
callDetail = 'Duration: ' + this.duration;
} else {
if (this.state.reconnectingCall) {
callDetail = 'Reconnecting the call...';
} else if (this.props.terminated) {
callDetail = 'Call ended';
} else if (this.state.callState === 'terminated') {
callDetail = this.finalDuration ? 'Call ended after ' + this.finalDuration : 'Call ended';
} else {
if (this.state.callState) {
if (this.state.callState === 'incoming') {
callDetail = 'Waiting for incoming call...';
} else {
callDetail = toTitleCase(this.state.callState);
}
} else if (this.state.direction) {
callDetail = 'Connecting', this.state.direction, 'call...';
} else {
callDetail = 'Connecting...';
}
}
}
+ if (this.props.bandwidth) {
+ callDetail = callDetail + ' - ' + this.props.bandwidth;
+ }
+
if (this.state.remoteUri && this.state.remoteUri.search('videoconference') > -1) {
displayName = this.state.remoteUri.split('@')[0];
header = (
);
} else {
header = (
);
}
}
return header
}
}
CallOverlay.propTypes = {
show: PropTypes.bool.isRequired,
remoteUri: PropTypes.string,
remoteDisplayName: PropTypes.string,
call: PropTypes.object,
connection: PropTypes.object,
reconnectingCall: PropTypes.bool,
- terminated : PropTypes.bool
+ terminated : PropTypes.bool,
+ bandwidth: PropTypes.string
};
export default CallOverlay;
diff --git a/app/components/ConferenceHeader.js b/app/components/ConferenceHeader.js
index 3df4c12..bc26378 100644
--- a/app/components/ConferenceHeader.js
+++ b/app/components/ConferenceHeader.js
@@ -1,104 +1,104 @@
import React, { useState, useEffect, useRef, Fragment } from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types';
import moment from 'moment';
import momentFormat from 'moment-duration-format';
import { Text, Appbar } from 'react-native-paper';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import styles from '../assets/styles/blink/_ConferenceHeader.scss';
const useInterval = (callback, delay) => {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
const ConferenceHeader = (props) => {
let [seconds, setSeconds] = useState(0);
useInterval(() => {
setSeconds(seconds + 1);
}, 1000);
const duration = moment.duration(seconds, 'seconds').format('hh:mm:ss', {trim: false});
let videoHeader;
let callButtons;
if (props.show) {
const participantCount = props.participants.length + 1;
// const callDetail = (
//
// {duration} - {participantCount} participant{participantCount > 1 ? 's' : ''}
//
// );
const room = props.remoteUri.split('@')[0];
let callDetail;
if (props.reconnectingCall) {
callDetail = 'Reconnecting call...';
} else if (props.terminated) {
callDetail = 'Call ended';
} else {
callDetail = `${duration} - ${participantCount} participant${participantCount > 1 ? 's' : ''}`;
}
- if (props.speed) {
- callDetail = callDetail + ' - ' + props.speed;
+ if (props.bandwidth) {
+ callDetail = callDetail + ' - ' + props.bandwidth;
}
videoHeader = (
{props.audioOnly ? null : props.buttons.top.right}
);
callButtons = (
{props.buttons.bottom}
);
}
return (
{videoHeader}
{callButtons}
);
}
ConferenceHeader.propTypes = {
show: PropTypes.bool.isRequired,
remoteUri: PropTypes.string.isRequired,
participants: PropTypes.array.isRequired,
buttons: PropTypes.object.isRequired,
reconnectingCall: PropTypes.bool,
audioOnly: PropTypes.bool,
terminated: PropTypes.bool,
- speed: PropTypes.string
+ bandwidth: PropTypes.string
};
export default ConferenceHeader;
diff --git a/app/components/VideoBox.js b/app/components/VideoBox.js
index 6933df8..3af209f 100644
--- a/app/components/VideoBox.js
+++ b/app/components/VideoBox.js
@@ -1,382 +1,389 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import dtmf from 'react-native-dtmf';
import debug from 'react-native-debug';
import autoBind from 'auto-bind';
import { IconButton, ActivityIndicator, Colors } from 'react-native-paper';
import { View, Dimensions, TouchableWithoutFeedback, Platform } from 'react-native';
import { RTCView } from 'react-native-webrtc';
import CallOverlay from './CallOverlay';
import EscalateConferenceModal from './EscalateConferenceModal';
import DTMFModal from './DTMFModal';
import config from '../config';
import styles from '../assets/styles/blink/_VideoBox.scss';
const DEBUG = debug('blinkrtc:Video');
debug.enable('*');
class VideoBox extends Component {
constructor(props) {
super(props);
autoBind(this);
this.state = {
call: this.props.call,
reconnectingCall: this.props.reconnectingCall,
audioMuted: this.props.muted,
mirror: true,
callOverlayVisible: true,
videoMuted: false,
localVideoShow: true,
remoteVideoShow: true,
remoteSharesScreen: false,
showEscalateConferenceModal: false,
localStream: this.props.call.getLocalStreams()[0],
remoteStream: this.props.call.getRemoteStreams()[0],
+ bandwidth: this.props.bandwidth,
showDtmfModal: false,
doorOpened: false
};
this.overlayTimer = null;
this.localVideo = React.createRef();
this.remoteVideo = React.createRef();
this.userHangup = false;
}
//getDerivedStateFromProps(nextProps, state) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.hasOwnProperty('muted')) {
this.setState({audioMuted: nextProps.muted});
}
+ if (nextProps.hasOwnProperty('bandwidth')) {
+ this.setState({bandwidth: nextProps.bandwidth});
+ }
+
if (nextProps.call && nextProps.call !== this.state.call) {
nextProps.call.on('stateChanged', this.callStateChanged);
if (this.state.call !== null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
this.setState({call: nextProps.call,
localStream: nextProps.call.getLocalStreams()[0],
remoteStream: nextProps.call.getRemoteStreams()[0]
});
}
if (nextProps.reconnectingCall != this.state.reconnectingCall) {
this.setState({reconnectingCall: nextProps.reconnectingCall});
}
}
callStateChanged(oldState, newState, data) {
this.forceUpdate();
}
openDoor() {
const tone = this.props.intercomDtmfTone;
DEBUG('DTMF tone sent to intercom: ' + tone);
this.setState({doorOpened: true});
this.forceUpdate();
dtmf.stopTone(); //don't play a tone at the same time as another
dtmf.playTone(dtmf['DTMF_' + tone], 1000);
if (this.state.call !== null && this.state.call.state === 'established') {
this.state.call.sendDtmf(tone);
}
}
componentDidMount() {
if (this.state.call) {
this.state.call.on('stateChanged', this.callStateChanged);
}
this.armOverlayTimer();
}
componentWillUnmount() {
if (this.state.call != null) {
this.state.call.removeListener('stateChanged', this.callStateChanged);
}
}
showDtmfModal() {
this.setState({showDtmfModal: true});
}
hideDtmfModal() {
this.setState({showDtmfModal: false});
}
handleFullscreen(event) {
event.preventDefault();
// this.toggleFullscreen();
}
handleRemoteVideoPlaying() {
this.setState({remoteVideoShow: true});
}
handleRemoteResize(event, target) {
const resolutions = [ '1280x720', '960x540', '640x480', '640x360', '480x270','320x180'];
const videoResolution = event.target.videoWidth + 'x' + event.target.videoHeight;
if (resolutions.indexOf(videoResolution) === -1) {
this.setState({remoteSharesScreen: true});
} else {
this.setState({remoteSharesScreen: false});
}
}
muteAudio(event) {
event.preventDefault();
this.props.toggleMute(this.state.call.id, !this.state.audioMuted);
}
muteVideo(event) {
event.preventDefault();
const localStream = this.state.localStream;
if (localStream.getVideoTracks().length > 0) {
const track = localStream.getVideoTracks()[0];
if(this.state.videoMuted) {
DEBUG('Unmute camera');
track.enabled = true;
this.setState({videoMuted: false});
} else {
DEBUG('Mute camera');
track.enabled = false;
this.setState({videoMuted: true});
}
}
}
toggleCamera(event) {
event.preventDefault();
const localStream = this.state.localStream;
if (localStream.getVideoTracks().length > 0) {
const track = localStream.getVideoTracks()[0];
track._switchCamera();
this.setState({mirror: !this.state.mirror});
}
}
hangupCall(event) {
event.preventDefault();
this.props.hangupCall('user_hangup_call');
this.userHangup = true;
}
cancelCall(event) {
event.preventDefault();
this.props.hangupCall('user_cancelled_call');
}
escalateToConference(participants) {
this.props.escalateToConference(participants);
}
armOverlayTimer() {
clearTimeout(this.overlayTimer);
this.overlayTimer = setTimeout(() => {
this.setState({callOverlayVisible: false});
}, 4000);
}
toggleCallOverlay() {
this.setState({callOverlayVisible: !this.state.callOverlayVisible});
}
toggleEscalateConferenceModal() {
this.setState({
callOverlayVisible : false,
showEscalateConferenceModal : !this.state.showEscalateConferenceModal
});
}
render() {
if (this.state.call === null) {
return null;
}
// 'mirror' : !this.state.call.sharingScreen && !this.props.generatedVideoTrack,
// we do not want mirrored local video once the call has started, just in preview
const localVideoClasses = classNames({
'video-thumbnail' : true,
'hidden' : !this.state.localVideoShow,
'animated' : true,
'fadeIn' : this.state.localVideoShow || this.state.videoMuted,
'fadeOut' : this.state.videoMuted,
'fit' : this.state.call.sharingScreen
});
const remoteVideoClasses = classNames({
'poster' : !this.state.remoteVideoShow,
'animated' : true,
'fadeIn' : this.state.remoteVideoShow,
'large' : true,
'fit' : this.state.remoteSharesScreen
});
let buttonContainerClass;
let buttons;
const muteButtonIcons = this.state.audioMuted ? 'microphone-off' : 'microphone';
const muteVideoButtonIcons = this.state.videoMuted ? 'video-off' : 'video';
const buttonClass = (Platform.OS === 'ios') ? styles.iosButton : styles.androidButton;
const buttonSize = this.props.isTablet ? 40 : 34;
if (this.props.isTablet) {
buttonContainerClass = this.props.orientation === 'landscape' ? styles.tabletLandscapeButtonContainer : styles.tabletPortraitButtonContainer;
userIconContainerClass = styles.tabletUserIconContainer;
} else {
buttonContainerClass = this.props.orientation === 'landscape' ? styles.landscapeButtonContainer : styles.portraitButtonContainer;
}
if (this.state.callOverlayVisible) {
let content = (
);
if (this.props.intercomDtmfTone) {
content = (
);
}
buttons = ({content});
}
const remoteStreamUrl = this.state.remoteStream ? this.state.remoteStream.toURL() : null
const show = this.state.callOverlayVisible || this.state.reconnectingCall;
return (
{this.state.remoteVideoShow && !this.state.reconnectingCall ?
: null }
{ this.state.localVideoShow ?
: null }
{this.state.reconnectingCall
?
: null
}
{buttons}
);
}
}
VideoBox.propTypes = {
call : PropTypes.object,
connection : PropTypes.object,
photo : PropTypes.string,
accountId : PropTypes.string,
remoteUri : PropTypes.string,
remoteDisplayName : PropTypes.string,
localMedia : PropTypes.object,
hangupCall : PropTypes.func,
+ bandwidth : PropTypes.string,
shareScreen : PropTypes.func,
escalateToConference : PropTypes.func,
generatedVideoTrack : PropTypes.bool,
callKeepSendDtmf : PropTypes.func,
toggleMute : PropTypes.func,
toggleSpeakerPhone : PropTypes.func,
speakerPhoneEnabled : PropTypes.bool,
intercomDtmfTone : PropTypes.string,
orientation : PropTypes.string,
isTablet : PropTypes.bool,
reconnectingCall : PropTypes.bool,
muted : PropTypes.bool
};
export default VideoBox;