mirror of https://github.com/jitsi/jitsi-meet
* Adds a dropdown indicator which displays the status of the internet connection. * It uses the same data as `https://network.callstats.io`. * The algorithm for the strings displayed to the user is also the one used on `network.callstas.io`.pull/7494/head jitsi-meet_4951
parent
10c2652a4f
commit
453c07cb17
@ -0,0 +1,60 @@ |
||||
.con-status { |
||||
position: absolute; |
||||
top: 40px; |
||||
width: 100%; |
||||
z-index: $toolbarZ + 3; |
||||
|
||||
&-container { |
||||
background: rgba(28, 32, 37, .5); |
||||
border-radius: 3px; |
||||
color: #fff; |
||||
font-size: 13px; |
||||
line-height: 20px; |
||||
margin: 0 auto; |
||||
width: 304px; |
||||
} |
||||
|
||||
&-header { |
||||
align-items: center; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
padding: 8px; |
||||
} |
||||
|
||||
&-circle { |
||||
border-radius: 50%; |
||||
display: inline-block; |
||||
padding: 4px; |
||||
} |
||||
|
||||
&--good { |
||||
background: #31B76A; |
||||
} |
||||
|
||||
&--poor { |
||||
background: #E12D2D; |
||||
} |
||||
|
||||
&--non-optimal { |
||||
background: #E39623; |
||||
} |
||||
|
||||
&-arrow { |
||||
&--up { |
||||
transform: rotate(180deg); |
||||
} |
||||
|
||||
&>svg { |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
|
||||
&-text { |
||||
text-align: center; |
||||
} |
||||
|
||||
&-details { |
||||
border-top: 1px solid #5E6D7A; |
||||
padding: 16px; |
||||
} |
||||
} |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,104 @@ |
||||
// @flow
|
||||
|
||||
import React, { useState } from 'react'; |
||||
|
||||
import { translate } from '../../../i18n'; |
||||
import { Icon, IconArrowDownSmall, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons'; |
||||
import { connect } from '../../../redux'; |
||||
import { CONNECTION_TYPE } from '../../constants'; |
||||
import { getConnectionData } from '../../functions'; |
||||
|
||||
type Props = { |
||||
|
||||
/** |
||||
* List of strings with details about the connection. |
||||
*/ |
||||
connectionDetails: string[], |
||||
|
||||
/** |
||||
* The type of the connection. Can be: 'none', 'poor', 'nonOptimal' or 'good'. |
||||
*/ |
||||
connectionType: string, |
||||
|
||||
/** |
||||
* Used for translation. |
||||
*/ |
||||
t: Function |
||||
} |
||||
|
||||
const CONNECTION_TYPE_MAP = { |
||||
[CONNECTION_TYPE.POOR]: { |
||||
connectionClass: 'con-status--poor', |
||||
icon: IconWifi1Bar, |
||||
connectionText: 'prejoin.connection.poor' |
||||
}, |
||||
[CONNECTION_TYPE.NON_OPTIMAL]: { |
||||
connectionClass: 'con-status--non-optimal', |
||||
icon: IconWifi2Bars, |
||||
connectionText: 'prejoin.connection.nonOptimal' |
||||
}, |
||||
[CONNECTION_TYPE.GOOD]: { |
||||
connectionClass: 'con-status--good', |
||||
icon: IconWifi3Bars, |
||||
connectionText: 'prejoin.connection.good' |
||||
} |
||||
}; |
||||
|
||||
/** |
||||
* Component displaying information related to the connection & audio/video quality. |
||||
* |
||||
* @param {Props} props - The props of the component. |
||||
* @returns {ReactElement} |
||||
*/ |
||||
function ConnectionStatus({ connectionDetails, t, connectionType }: Props) { |
||||
if (connectionType === CONNECTION_TYPE.NONE) { |
||||
return null; |
||||
} |
||||
|
||||
const { connectionClass, icon, connectionText } = CONNECTION_TYPE_MAP[connectionType]; |
||||
const [ showDetails, toggleDetails ] = useState(false); |
||||
const arrowClassName = showDetails |
||||
? 'con-status-arrow con-status-arrow--up' |
||||
: 'con-status-arrow'; |
||||
const detailsText = connectionDetails.map(t).join(' '); |
||||
|
||||
return ( |
||||
<div className = 'con-status'> |
||||
<div className = 'con-status-container'> |
||||
<div className = 'con-status-header'> |
||||
<div className = { `con-status-circle ${connectionClass}` }> |
||||
<Icon |
||||
size = { 16 } |
||||
src = { icon } /> |
||||
</div> |
||||
<span className = 'con-status-text'>{t(connectionText)}</span> |
||||
<Icon |
||||
className = { arrowClassName } |
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { () => toggleDetails(!showDetails) } |
||||
size = { 24 } |
||||
src = { IconArrowDownSmall } /> |
||||
</div> |
||||
{ showDetails |
||||
&& <div className = 'con-status-details'>{detailsText}</div> } |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Maps (parts of) the redux state to the React {@code Component} props. |
||||
* |
||||
* @param {Object} state - The redux state. |
||||
* @returns {Object} |
||||
*/ |
||||
function mapStateToProps(state): Object { |
||||
const { connectionDetails, connectionType } = getConnectionData(state); |
||||
|
||||
return { |
||||
connectionDetails, |
||||
connectionType |
||||
}; |
||||
} |
||||
|
||||
export default translate(connect(mapStateToProps)(ConnectionStatus)); |
@ -0,0 +1,8 @@ |
||||
// @flow
|
||||
|
||||
export const CONNECTION_TYPE = { |
||||
GOOD: 'good', |
||||
NON_OPTIMAL: 'nonOptimal', |
||||
NONE: 'none', |
||||
POOR: 'poor' |
||||
}; |
@ -0,0 +1,142 @@ |
||||
import { findIndex } from 'lodash'; |
||||
|
||||
import { CONNECTION_TYPE } from './constants'; |
||||
|
||||
const LOSS_AUDIO_THRESHOLDS = [ 0.33, 0.05 ]; |
||||
const LOSS_VIDEO_THRESHOLDS = [ 0.33, 0.1, 0.05 ]; |
||||
|
||||
const THROUGHPUT_AUDIO_THRESHOLDS = [ 8, 20 ]; |
||||
const THROUGHPUT_VIDEO_THRESHOLDS = [ 60, 750 ]; |
||||
|
||||
/** |
||||
* Returns the level based on a list of thresholds. |
||||
* |
||||
* @param {number[]} thresholds - The thresholds array. |
||||
* @param {number} value - The value against which the level is calculated. |
||||
* @param {boolean} descending - The order based on which the level is calculated. |
||||
* |
||||
* @returns {number} |
||||
*/ |
||||
function _getLevel(thresholds, value, descending = true) { |
||||
let predicate; |
||||
|
||||
if (descending) { |
||||
predicate = function(threshold) { |
||||
return value > threshold; |
||||
}; |
||||
} else { |
||||
predicate = function(threshold) { |
||||
return value < threshold; |
||||
}; |
||||
} |
||||
|
||||
const i = findIndex(thresholds, predicate); |
||||
|
||||
if (i === -1) { |
||||
return thresholds.length; |
||||
} |
||||
|
||||
return i; |
||||
} |
||||
|
||||
/** |
||||
* Returns the connection details from the test results. |
||||
* |
||||
* @param {{ |
||||
* fractionalLoss: number, |
||||
* throughput: number |
||||
* }} testResults - The state of the app. |
||||
* |
||||
* @returns {{ |
||||
* connectionType: string, |
||||
* connectionDetails: string[] |
||||
* }} |
||||
*/ |
||||
function _getConnectionDataFromTestResults({ fractionalLoss: l, throughput: t }) { |
||||
const loss = { |
||||
audioQuality: _getLevel(LOSS_AUDIO_THRESHOLDS, l), |
||||
videoQuality: _getLevel(LOSS_VIDEO_THRESHOLDS, l) |
||||
}; |
||||
const throughput = { |
||||
audioQuality: _getLevel(THROUGHPUT_AUDIO_THRESHOLDS, t, false), |
||||
videoQuality: _getLevel(THROUGHPUT_VIDEO_THRESHOLDS, t, false) |
||||
}; |
||||
let connectionType = CONNECTION_TYPE.NONE; |
||||
const connectionDetails = []; |
||||
|
||||
if (throughput.audioQuality === 0 || loss.audioQuality === 0) { |
||||
// Calls are impossible.
|
||||
connectionType = CONNECTION_TYPE.POOR; |
||||
connectionDetails.push('prejoin.connectionDetails.veryPoorConnection'); |
||||
} else if ( |
||||
throughput.audioQuality === 2 |
||||
&& throughput.videoQuality === 2 |
||||
&& loss.audioQuality === 2 |
||||
&& loss.videoQuality === 3 |
||||
) { |
||||
// Ideal conditions for both audio and video. Show only one message.
|
||||
connectionType = CONNECTION_TYPE.GOOD; |
||||
connectionDetails.push('prejoin.connectionDetails.goodQuality'); |
||||
} else { |
||||
connectionType = CONNECTION_TYPE.NON_OPTIMAL; |
||||
|
||||
if (throughput.audioQuality === 1) { |
||||
// Minimum requirements for a call are met.
|
||||
connectionDetails.push('prejoin.connectionDetails.audioLowNoVideo'); |
||||
} else { |
||||
// There are two paragraphs: one saying something about audio and the other about video.
|
||||
if (loss.audioQuality === 1) { |
||||
connectionDetails.push('prejoin.connectionDetails.audioClipping'); |
||||
} else { |
||||
connectionDetails.push('prejoin.connectionDetails.audioHighQuality'); |
||||
} |
||||
|
||||
if (throughput.videoQuality === 0 || loss.videoQuality === 0) { |
||||
connectionDetails.push('prejoin.connectionDetails.noVideo'); |
||||
} else if (throughput.videoQuality === 1) { |
||||
connectionDetails.push('prejoin.connectionDetails.videoLowQuality'); |
||||
} else if (loss.videoQuality === 1) { |
||||
connectionDetails.push('prejoin.connectionDetails.videoFreezing'); |
||||
} else if (loss.videoQuality === 2) { |
||||
connectionDetails.push('prejoin.connectionDetails.videoTearing'); |
||||
} else { |
||||
connectionDetails.push('prejoin.connectionDetails.videoHighQuality'); |
||||
} |
||||
} |
||||
connectionDetails.push('prejoin.connectionDetails.undetectable'); |
||||
} |
||||
|
||||
return { |
||||
connectionType, |
||||
connectionDetails |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Selector for determining the connection type & details. |
||||
* |
||||
* @param {Object} state - The state of the app. |
||||
* @returns {{ |
||||
* connectionType: string, |
||||
* connectionDetails: string[] |
||||
* }} |
||||
*/ |
||||
export function getConnectionData(state) { |
||||
const { precallTestResults } = state['features/prejoin']; |
||||
|
||||
if (precallTestResults) { |
||||
if (precallTestResults.mediaConnectivity) { |
||||
return _getConnectionDataFromTestResults(precallTestResults); |
||||
} |
||||
|
||||
return { |
||||
connectionType: CONNECTION_TYPE.POOR, |
||||
connectionDetails: [ 'prejoin.connectionDetails.noMediaConnectivity' ] |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
connectionType: CONNECTION_TYPE.NONE, |
||||
connectionDetails: [] |
||||
}; |
||||
} |
Loading…
Reference in new issue