This commit is contained in:
80
react/features/speaker-stats/actionTypes.ts
Normal file
80
react/features/speaker-stats/actionTypes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Action type to start search.
|
||||
*
|
||||
* {
|
||||
* type: INIT_SEARCH
|
||||
* }
|
||||
*/
|
||||
export const INIT_SEARCH = 'INIT_SEARCH';
|
||||
|
||||
/**
|
||||
* Action type to start stats retrieval.
|
||||
*
|
||||
* {
|
||||
* type: INIT_UPDATE_STATS,
|
||||
* getSpeakerStats: Function
|
||||
* }
|
||||
*/
|
||||
export const INIT_UPDATE_STATS = 'INIT_UPDATE_STATS';
|
||||
|
||||
/**
|
||||
* Action type to update stats.
|
||||
*
|
||||
* {
|
||||
* type: UPDATE_STATS,
|
||||
* stats: Object
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_STATS = 'UPDATE_STATS';
|
||||
|
||||
/**
|
||||
* Action type to update the speaker stats order.
|
||||
* {
|
||||
* type: UPDATE_SORTED_SPEAKER_STATS_IDS
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_SORTED_SPEAKER_STATS_IDS = 'UPDATE_SORTED_SPEAKER_STATS_IDS'
|
||||
|
||||
/**
|
||||
* Action type to initiate reordering of the stats.
|
||||
*
|
||||
* {
|
||||
* type: INIT_REORDER_STATS
|
||||
* }
|
||||
*/
|
||||
export const INIT_REORDER_STATS = 'INIT_REORDER_STATS';
|
||||
|
||||
/**
|
||||
* Action type to reset the search criteria.
|
||||
*
|
||||
* {
|
||||
* type: RESET_SEARCH_CRITERIA
|
||||
* }
|
||||
*/
|
||||
export const RESET_SEARCH_CRITERIA = 'RESET_SEARCH_CRITERIA'
|
||||
|
||||
/**
|
||||
* Action type to toggle the face expressions grid.
|
||||
* {
|
||||
* type: TOGGLE_FACE_EXPRESSIONS
|
||||
* }
|
||||
*/
|
||||
export const TOGGLE_FACE_EXPRESSIONS = 'SHOW_FACE_EXPRESSIONS';
|
||||
|
||||
|
||||
export const INCREASE_ZOOM = 'INCREASE_ZOOM';
|
||||
|
||||
export const DECREASE_ZOOM = 'DECREASE_ZOOM';
|
||||
|
||||
export const ADD_TO_OFFSET = 'ADD_TO_OFFSET';
|
||||
|
||||
export const SET_OFFSET = 'RESET_OFFSET';
|
||||
|
||||
export const ADD_TO_OFFSET_LEFT = 'ADD_TO_OFFSET_LEFT';
|
||||
|
||||
export const ADD_TO_OFFSET_RIGHT = 'ADD_TO_OFFSET_RIGHT';
|
||||
|
||||
export const SET_TIMELINE_BOUNDARY = 'SET_TIMELINE_BOUNDARY';
|
||||
|
||||
export const SET_PANNING = 'SET_PANNING';
|
||||
|
||||
231
react/features/speaker-stats/actions.any.ts
Normal file
231
react/features/speaker-stats/actions.any.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { IStore } from '../app/types';
|
||||
|
||||
import {
|
||||
ADD_TO_OFFSET,
|
||||
ADD_TO_OFFSET_LEFT,
|
||||
ADD_TO_OFFSET_RIGHT,
|
||||
INIT_REORDER_STATS,
|
||||
INIT_SEARCH,
|
||||
INIT_UPDATE_STATS,
|
||||
RESET_SEARCH_CRITERIA,
|
||||
SET_PANNING,
|
||||
SET_TIMELINE_BOUNDARY,
|
||||
TOGGLE_FACE_EXPRESSIONS,
|
||||
UPDATE_SORTED_SPEAKER_STATS_IDS,
|
||||
UPDATE_STATS
|
||||
} from './actionTypes';
|
||||
import { MINIMUM_INTERVAL } from './constants';
|
||||
import { getCurrentDuration, getTimelineBoundaries } from './functions';
|
||||
import { ISpeakerStats } from './reducer';
|
||||
|
||||
/**
|
||||
* Starts a search by criteria.
|
||||
*
|
||||
* @param {string} criteria - The search criteria.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function initSearch(criteria: string) {
|
||||
return {
|
||||
type: INIT_SEARCH,
|
||||
criteria
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the new stats and triggers update.
|
||||
*
|
||||
* @param {Function} getSpeakerStats - Function to get the speaker stats.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function initUpdateStats(getSpeakerStats: () => ISpeakerStats) {
|
||||
return {
|
||||
type: INIT_UPDATE_STATS,
|
||||
getSpeakerStats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stats with new stats.
|
||||
*
|
||||
* @param {Object} stats - The new stats.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function updateStats(stats: Object) {
|
||||
return {
|
||||
type: UPDATE_STATS,
|
||||
stats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the speaker stats order.
|
||||
*
|
||||
* @param {Array<string>} participantIds - Participant ids.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function updateSortedSpeakerStatsIds(participantIds: Array<string>) {
|
||||
return {
|
||||
type: UPDATE_SORTED_SPEAKER_STATS_IDS,
|
||||
participantIds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates reordering of the stats.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function initReorderStats() {
|
||||
return {
|
||||
type: INIT_REORDER_STATS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the search criteria.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function resetSearchCriteria() {
|
||||
return {
|
||||
type: RESET_SEARCH_CRITERIA
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the face expressions grid.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function toggleFaceExpressions() {
|
||||
return {
|
||||
type: TOGGLE_FACE_EXPRESSIONS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a value to the boundary offset of the timeline.
|
||||
*
|
||||
* @param {number} value - The value to be added.
|
||||
* @param {number} left - The left boundary.
|
||||
* @param {number} right - The right boundary.
|
||||
* @param {number} currentDuration - The currentDuration of the conference.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function addToOffset(value: number) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { left, right } = getTimelineBoundaries(state);
|
||||
const currentDuration = getCurrentDuration(state) ?? 0;
|
||||
const newLeft = left + value;
|
||||
const newRight = right + value;
|
||||
|
||||
if (newLeft >= 0 && newRight <= currentDuration) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET,
|
||||
value
|
||||
});
|
||||
} else if (newLeft < 0) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET,
|
||||
value: -left
|
||||
});
|
||||
} else if (newRight > currentDuration) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET,
|
||||
value: currentDuration - right
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the value to the offset of the left boundary for the timeline.
|
||||
*
|
||||
* @param {number} value - The new value for the offset.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function addToOffsetLeft(value: number) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { left, right } = getTimelineBoundaries(state);
|
||||
const newLeft = left + value;
|
||||
|
||||
if (newLeft >= 0 && right - newLeft > MINIMUM_INTERVAL) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET_LEFT,
|
||||
value
|
||||
});
|
||||
} else if (newLeft < 0) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET_LEFT,
|
||||
value: -left
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the value to the offset of the right boundary for the timeline.
|
||||
*
|
||||
* @param {number} value - The new value for the offset.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function addToOffsetRight(value: number) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
const { left, right } = getTimelineBoundaries(state);
|
||||
const currentDuration = getCurrentDuration(state) ?? 0;
|
||||
const newRight = right + value;
|
||||
|
||||
if (newRight <= currentDuration && newRight - left > MINIMUM_INTERVAL) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET_RIGHT,
|
||||
value
|
||||
});
|
||||
} else if (newRight > currentDuration) {
|
||||
dispatch({
|
||||
type: ADD_TO_OFFSET_RIGHT,
|
||||
value: currentDuration - right
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current time boundary of the timeline, when zoomed in.
|
||||
*
|
||||
* @param {number} boundary - The current time boundary.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setTimelineBoundary(boundary: number) {
|
||||
return {
|
||||
type: SET_TIMELINE_BOUNDARY,
|
||||
boundary
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current time boundary of the timeline, when zoomed out full.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function clearTimelineBoundary() {
|
||||
return {
|
||||
type: SET_TIMELINE_BOUNDARY,
|
||||
boundary: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state of the timeline panning.
|
||||
*
|
||||
* @param {Object} panning - The state of the timeline panning.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function setTimelinePanning(panning: { active: boolean; x: number; }) {
|
||||
return {
|
||||
type: SET_PANNING,
|
||||
panning
|
||||
};
|
||||
}
|
||||
1
react/features/speaker-stats/actions.native.ts
Normal file
1
react/features/speaker-stats/actions.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './actions.any';
|
||||
1
react/features/speaker-stats/actions.web.ts
Normal file
1
react/features/speaker-stats/actions.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './actions.any';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IconConnection } from '../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
|
||||
|
||||
/**
|
||||
* Implementation of a button for opening speaker stats dialog.
|
||||
*/
|
||||
class AbstractSpeakerStatsButton extends AbstractButton<AbstractButtonProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.speakerStats';
|
||||
override icon = IconConnection;
|
||||
override label = 'toolbar.speakerStats';
|
||||
override tooltip = 'toolbar.speakerStats';
|
||||
}
|
||||
|
||||
export default AbstractSpeakerStatsButton;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { getLocalParticipant } from '../../base/participants/functions';
|
||||
import { initUpdateStats } from '../actions.any';
|
||||
import {
|
||||
SPEAKER_STATS_RELOAD_INTERVAL
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* Component that renders the list of speaker stats.
|
||||
*
|
||||
* @param {Function} speakerStatsItem - React element tu use when rendering.
|
||||
* @param {Object} itemStyles - Styles for the speaker stats item.
|
||||
* @returns {Function}
|
||||
*/
|
||||
const abstractSpeakerStatsList = (speakerStatsItem: Function): Function[] => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
|
||||
const {
|
||||
stats: speakerStats,
|
||||
showFaceExpressions,
|
||||
sortedSpeakerStatsIds
|
||||
} = useSelector((state: IReduxState) => state['features/speaker-stats']);
|
||||
const localParticipant = useSelector(getLocalParticipant);
|
||||
const { defaultRemoteDisplayName } = useSelector(
|
||||
(state: IReduxState) => state['features/base/config']) || {};
|
||||
const { faceLandmarks: faceLandmarksConfig } = useSelector((state: IReduxState) =>
|
||||
state['features/base/config']) || {};
|
||||
const { faceLandmarks } = useSelector((state: IReduxState) => state['features/face-landmarks'])
|
||||
|| { faceLandmarks: [] };
|
||||
const reloadInterval = useRef<number>();
|
||||
|
||||
/**
|
||||
* Update the internal state with the latest speaker stats.
|
||||
*
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
const getSpeakerStats = useCallback(() => {
|
||||
const stats = conference?.getSpeakerStats();
|
||||
|
||||
for (const userId in stats) {
|
||||
if (stats[userId]) {
|
||||
if (stats[userId].isLocalStats()) {
|
||||
const meString = t('me');
|
||||
|
||||
stats[userId].setDisplayName(
|
||||
localParticipant?.name
|
||||
? `${localParticipant.name} (${meString})`
|
||||
: meString
|
||||
);
|
||||
|
||||
if (faceLandmarksConfig?.enableDisplayFaceExpressions) {
|
||||
stats[userId].setFaceLandmarks(faceLandmarks);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats[userId].getDisplayName()) {
|
||||
stats[userId].setDisplayName(
|
||||
conference?.getParticipantById(userId)?.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats ?? {};
|
||||
}, [ faceLandmarks ]);
|
||||
|
||||
const updateStats = useCallback(
|
||||
() => dispatch(initUpdateStats(getSpeakerStats)),
|
||||
[ dispatch, initUpdateStats, getSpeakerStats ]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadInterval.current = window.setInterval(() => {
|
||||
updateStats();
|
||||
}, SPEAKER_STATS_RELOAD_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (reloadInterval.current) {
|
||||
clearInterval(reloadInterval.current);
|
||||
}
|
||||
};
|
||||
}, [ faceLandmarks ]);
|
||||
|
||||
const localSpeakerStats = Object.keys(speakerStats).length === 0 ? getSpeakerStats() : speakerStats;
|
||||
const localSortedSpeakerStatsIds
|
||||
= sortedSpeakerStatsIds.length === 0 ? Object.keys(localSpeakerStats) : sortedSpeakerStatsIds;
|
||||
|
||||
const userIds = localSortedSpeakerStatsIds.filter(id => localSpeakerStats[id] && !localSpeakerStats[id].hidden);
|
||||
|
||||
return userIds.map(userId => {
|
||||
const statsModel = localSpeakerStats[userId];
|
||||
const props = {
|
||||
isDominantSpeaker: statsModel.isDominantSpeaker(),
|
||||
dominantSpeakerTime: statsModel.getTotalDominantSpeakerTime(),
|
||||
participantId: userId,
|
||||
hasLeft: statsModel.hasLeft(),
|
||||
faceLandmarks: showFaceExpressions ? statsModel.getFaceLandmarks() : undefined,
|
||||
hidden: statsModel.hidden,
|
||||
showFaceExpressions,
|
||||
displayName: statsModel.getDisplayName() || defaultRemoteDisplayName,
|
||||
t
|
||||
};
|
||||
|
||||
return speakerStatsItem(props);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export default abstractSpeakerStatsList;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { resetSearchCriteria } from '../../actions.native';
|
||||
|
||||
import SpeakerStatsList from './SpeakerStatsList';
|
||||
import SpeakerStatsSearch from './SpeakerStatsSearch';
|
||||
import style from './styles';
|
||||
|
||||
/**
|
||||
* Component that renders the list of speaker stats.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SpeakerStats = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetSearchCriteria());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
style = { style.speakerStatsContainer }>
|
||||
<SpeakerStatsSearch />
|
||||
<SpeakerStatsList />
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStats;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { SPEAKERSTATS_ENABLED } from '../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../base/flags/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import AbstractSpeakerStatsButton from '../AbstractSpeakerStatsButton';
|
||||
|
||||
/**
|
||||
* Implementation of a button for opening speaker stats dialog.
|
||||
*/
|
||||
class SpeakerStatsButton extends AbstractSpeakerStatsButton {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
sendAnalytics(createToolbarEvent('speaker.stats'));
|
||||
|
||||
return navigate(screen.conference.speakerStats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code SpeakerStatsButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const enabled = getFeatureFlag(state, SPEAKERSTATS_ENABLED, true);
|
||||
|
||||
return {
|
||||
visible: enabled
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default translate(connect(_mapStateToProps)(SpeakerStatsButton));
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import StatelessAvatar from '../../../base/avatar/components/native/StatelessAvatar';
|
||||
import { getInitials } from '../../../base/avatar/functions';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
import TimeElapsed from './TimeElapsed';
|
||||
import style from './styles';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The name of the participant.
|
||||
*/
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* The total milliseconds the participant has been dominant speaker.
|
||||
*/
|
||||
dominantSpeakerTime: number;
|
||||
|
||||
/**
|
||||
* True if the participant is no longer in the meeting.
|
||||
*/
|
||||
hasLeft: boolean;
|
||||
|
||||
/**
|
||||
* True if the participant is currently the dominant speaker.
|
||||
*/
|
||||
isDominantSpeaker: boolean;
|
||||
|
||||
/**
|
||||
* The id of the user.
|
||||
*/
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
const SpeakerStatsItem = (props: IProps) =>
|
||||
(
|
||||
<View
|
||||
key = { props.participantId }
|
||||
style = { style.speakerStatsItemContainer as ViewStyle }>
|
||||
<View style = { style.speakerStatsAvatar }>
|
||||
{
|
||||
props.hasLeft ? (
|
||||
<StatelessAvatar
|
||||
color = { BaseTheme.palette.ui05 }
|
||||
initials = { getInitials(props.displayName) }
|
||||
size = { BaseTheme.spacing[5] } />
|
||||
) : (
|
||||
<Avatar
|
||||
className = 'userAvatar'
|
||||
participantId = { props.participantId }
|
||||
size = { BaseTheme.spacing[5] } />
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<View style = { style.speakerStatsNameTime as ViewStyle } >
|
||||
<Text style = { [ style.speakerStatsText, props.hasLeft && style.speakerStatsLeft ] }>
|
||||
{props.displayName}
|
||||
</Text>
|
||||
<TimeElapsed
|
||||
style = { [
|
||||
style.speakerStatsText,
|
||||
style.speakerStatsTime,
|
||||
props.isDominantSpeaker && style.speakerStatsDominant,
|
||||
props.hasLeft && style.speakerStatsLeft
|
||||
] }
|
||||
time = { props.dominantSpeakerTime } />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default SpeakerStatsItem;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import abstractSpeakerStatsList from '../AbstractSpeakerStatsList';
|
||||
|
||||
import SpeakerStatsItem from './SpeakerStatsItem';
|
||||
|
||||
/**
|
||||
* Component that renders the list of speaker stats.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SpeakerStatsList = () => {
|
||||
const items = abstractSpeakerStatsList(SpeakerStatsItem);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{items}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStatsList;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withTheme } from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IconSearch } from '../../../base/icons/svg';
|
||||
import Input from '../../../base/ui/components/native/Input';
|
||||
import { escapeRegexp } from '../../../base/util/helpers';
|
||||
import { initSearch } from '../../actions.native';
|
||||
import { isSpeakerStatsSearchDisabled } from '../../functions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* React component for display an individual user's speaker stats.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SpeakerStatsSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const onSearch = useCallback((criteria = '') => {
|
||||
dispatch(initSearch(escapeRegexp(criteria)));
|
||||
setSearchQuery(escapeRegexp(criteria));
|
||||
}, [ dispatch ]);
|
||||
|
||||
|
||||
const disableSpeakerStatsSearch = useSelector(isSpeakerStatsSearchDisabled);
|
||||
|
||||
if (disableSpeakerStatsSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
accessibilityLabel = { t('speakerStats.searchHint') }
|
||||
clearable = { true }
|
||||
customStyles = {{ container: styles.customContainer }}
|
||||
icon = { IconSearch }
|
||||
onChange = { onSearch }
|
||||
placeholder = { t('speakerStats.search') }
|
||||
value = { searchQuery } />
|
||||
);
|
||||
};
|
||||
|
||||
export default withTheme(SpeakerStatsSearch);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { createLocalizedTime } from '../timeFunctions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link TimeElapsed}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Style for text.
|
||||
*/
|
||||
style: Object;
|
||||
|
||||
/**
|
||||
* The milliseconds to be converted into a human-readable format.
|
||||
*/
|
||||
time: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React component for displaying total time elapsed. Converts a total count of
|
||||
* milliseconds into a more humanized form: "# hours, # minutes, # seconds".
|
||||
* With a time of 0, "0s" will be displayed.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class TimeElapsed extends PureComponent<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { style, time, t } = this.props;
|
||||
const timeElapsed = createLocalizedTime(time, t);
|
||||
|
||||
return (
|
||||
<Text style = { style }>
|
||||
{ timeElapsed }
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(TimeElapsed);
|
||||
55
react/features/speaker-stats/components/native/styles.ts
Normal file
55
react/features/speaker-stats/components/native/styles.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
export default {
|
||||
|
||||
customContainer: {
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
speakerStatsContainer: {
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: 'auto',
|
||||
paddingHorizontal: BaseTheme.spacing[3],
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
speakerStatsItemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'stretch',
|
||||
height: BaseTheme.spacing[9],
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
speakerStatsAvatar: {
|
||||
width: BaseTheme.spacing[5],
|
||||
height: BaseTheme.spacing[5],
|
||||
marginRight: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
speakerStatsNameTime: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
speakerStatsText: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
speakerStatsTime: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4
|
||||
},
|
||||
|
||||
speakerStatsDominant: {
|
||||
backgroundColor: BaseTheme.palette.success02
|
||||
},
|
||||
|
||||
speakerStatsLeft: {
|
||||
color: BaseTheme.palette.text03
|
||||
}
|
||||
};
|
||||
88
react/features/speaker-stats/components/timeFunctions.ts
Normal file
88
react/features/speaker-stats/components/timeFunctions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Counts how many whole hours are included in the given time total.
|
||||
*
|
||||
* @param {number} milliseconds - The millisecond total to get hours from.
|
||||
* @private
|
||||
* @returns {number}
|
||||
*/
|
||||
function getHoursCount(milliseconds: number) {
|
||||
return Math.floor(milliseconds / (60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts how many whole minutes are included in the given time total.
|
||||
*
|
||||
* @param {number} milliseconds - The millisecond total to get minutes from.
|
||||
* @private
|
||||
* @returns {number}
|
||||
*/
|
||||
function getMinutesCount(milliseconds: number) {
|
||||
return Math.floor(milliseconds / (60 * 1000) % 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts how many whole seconds are included in the given time total.
|
||||
*
|
||||
* @param {number} milliseconds - The millisecond total to get seconds from.
|
||||
* @private
|
||||
* @returns {number}
|
||||
*/
|
||||
function getSecondsCount(milliseconds: number) {
|
||||
return Math.floor(milliseconds / 1000 % 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates human readable localized time string.
|
||||
*
|
||||
* @param {number} time - Value in milliseconds.
|
||||
* @param {Function} t - Translate function.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function createLocalizedTime(time: number, t: Function) {
|
||||
const hours = getHoursCount(time);
|
||||
const minutes = getMinutesCount(time);
|
||||
const seconds = getSecondsCount(time);
|
||||
const timeElapsed = [];
|
||||
|
||||
if (hours) {
|
||||
const hourPassed
|
||||
= createTimeDisplay(hours, 'speakerStats.hours', t);
|
||||
|
||||
timeElapsed.push(hourPassed);
|
||||
}
|
||||
|
||||
if (hours || minutes) {
|
||||
const minutesPassed
|
||||
= createTimeDisplay(
|
||||
minutes,
|
||||
'speakerStats.minutes',
|
||||
t);
|
||||
|
||||
timeElapsed.push(minutesPassed);
|
||||
}
|
||||
|
||||
const secondsPassed
|
||||
= createTimeDisplay(
|
||||
seconds,
|
||||
'speakerStats.seconds',
|
||||
t);
|
||||
|
||||
timeElapsed.push(secondsPassed);
|
||||
|
||||
return timeElapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string to display the passed in count and a count noun.
|
||||
*
|
||||
* @private
|
||||
* @param {number} count - The number used for display and to check for
|
||||
* count noun plurality.
|
||||
* @param {string} countNounKey - Translation key for the time's count noun.
|
||||
* @param {Function} t - What is being counted. Used as the element's
|
||||
* key for react to iterate upon.
|
||||
* @returns {string}
|
||||
*/
|
||||
function createTimeDisplay(count: number, countNounKey: string, t: Function) {
|
||||
return t(countNounKey, { count });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Switch from '../../../base/ui/components/web/Switch';
|
||||
|
||||
const useStyles = makeStyles()((theme: Theme) => {
|
||||
return {
|
||||
switchContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
switchLabel: {
|
||||
marginRight: 10,
|
||||
...theme.typography.bodyShortRegular
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link ToggleFaceExpressionsButton}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The function to initiate the change in the speaker stats table.
|
||||
*/
|
||||
onChange: (checked?: boolean) => void;
|
||||
|
||||
/**
|
||||
* The state of the button.
|
||||
*/
|
||||
showFaceExpressions: boolean;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* React component for toggling face expressions grid.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
export default function FaceExpressionsSwitch({ onChange, showFaceExpressions }: IProps) {
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className = { classes.switchContainer } >
|
||||
<label
|
||||
className = { classes.switchLabel }
|
||||
htmlFor = 'face-expressions-switch'>
|
||||
{ t('speakerStats.displayEmotions')}
|
||||
</label>
|
||||
<Switch
|
||||
checked = { showFaceExpressions }
|
||||
id = 'face-expressions-switch'
|
||||
onChange = { onChange } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
react/features/speaker-stats/components/web/SpeakerStats.tsx
Normal file
283
react/features/speaker-stats/components/web/SpeakerStats.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconEmotionsAngry,
|
||||
IconEmotionsDisgusted,
|
||||
IconEmotionsFearful,
|
||||
IconEmotionsHappy,
|
||||
IconEmotionsNeutral,
|
||||
IconEmotionsSad,
|
||||
IconEmotionsSurprised
|
||||
} from '../../../base/icons/svg';
|
||||
import Tooltip from '../../../base/tooltip/components/Tooltip';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import { escapeRegexp } from '../../../base/util/helpers';
|
||||
import { initSearch, resetSearchCriteria, toggleFaceExpressions } from '../../actions.any';
|
||||
import {
|
||||
DISPLAY_SWITCH_BREAKPOINT,
|
||||
MOBILE_BREAKPOINT
|
||||
} from '../../constants';
|
||||
|
||||
import FaceExpressionsSwitch from './FaceExpressionsSwitch';
|
||||
import SpeakerStatsLabels from './SpeakerStatsLabels';
|
||||
import SpeakerStatsList from './SpeakerStatsList';
|
||||
import SpeakerStatsSearch from './SpeakerStatsSearch';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
speakerStats: {
|
||||
'& .header': {
|
||||
position: 'fixed',
|
||||
backgroundColor: theme.palette.ui01,
|
||||
paddingLeft: theme.spacing(4),
|
||||
paddingRight: theme.spacing(4),
|
||||
marginLeft: `-${theme.spacing(4)}`,
|
||||
'&.large': {
|
||||
width: '616px'
|
||||
},
|
||||
'&.medium': {
|
||||
width: '352px'
|
||||
},
|
||||
'@media (max-width: 448px)': {
|
||||
width: 'calc(100% - 48px) !important'
|
||||
},
|
||||
'& .upper-header': {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
'& .search-switch-container': {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
'& .search-container': {
|
||||
width: 175,
|
||||
marginRight: theme.spacing(3)
|
||||
},
|
||||
'& .search-container-full-width': {
|
||||
width: '100%'
|
||||
}
|
||||
},
|
||||
'& .emotions-icons': {
|
||||
display: 'flex',
|
||||
'& svg': {
|
||||
fill: '#000'
|
||||
},
|
||||
'&>div': {
|
||||
marginRight: theme.spacing(3)
|
||||
},
|
||||
'&>div:last-child': {
|
||||
marginRight: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'& .row': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& .name-time': {
|
||||
width: 'calc(100% - 48px)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
'&.expressions-on': {
|
||||
width: 'calc(47% - 48px)',
|
||||
marginRight: theme.spacing(4)
|
||||
}
|
||||
},
|
||||
'& .timeline-container': {
|
||||
height: '100%',
|
||||
width: `calc(53% - ${theme.spacing(4)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.palette.ui02,
|
||||
borderLeftStyle: 'solid',
|
||||
'& .timeline': {
|
||||
height: theme.spacing(2),
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
'&>div': {
|
||||
marginRight: theme.spacing(1),
|
||||
borderRadius: 5
|
||||
},
|
||||
'&>div:first-child': {
|
||||
borderRadius: '0 5px 5px 0'
|
||||
},
|
||||
'&>div:last-child': {
|
||||
marginRight: 0,
|
||||
borderRadius: '5px 0 0 5px'
|
||||
}
|
||||
}
|
||||
},
|
||||
'& .axis-container': {
|
||||
height: '100%',
|
||||
width: `calc(53% - ${theme.spacing(6)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: theme.spacing(3),
|
||||
'& div': {
|
||||
borderRadius: 5
|
||||
},
|
||||
'& .axis': {
|
||||
height: theme.spacing(1),
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
backgroundColor: theme.palette.ui03,
|
||||
position: 'relative',
|
||||
'& .left-bound': {
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
left: 0
|
||||
},
|
||||
'& .right-bound': {
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 0
|
||||
},
|
||||
'& .handler': {
|
||||
position: 'absolute',
|
||||
backgroundColor: theme.palette.ui09,
|
||||
height: 12,
|
||||
marginTop: -4,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
'& .resize': {
|
||||
height: '100%',
|
||||
width: 5,
|
||||
cursor: 'col-resize'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'& .separator': {
|
||||
width: 'calc(100% + 48px)',
|
||||
height: 1,
|
||||
marginLeft: -24,
|
||||
backgroundColor: theme.palette.ui02
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const EMOTIONS_LEGEND = [
|
||||
{
|
||||
translationKey: 'speakerStats.neutral',
|
||||
icon: IconEmotionsNeutral
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.happy',
|
||||
icon: IconEmotionsHappy
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.surprised',
|
||||
icon: IconEmotionsSurprised
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.sad',
|
||||
icon: IconEmotionsSad
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.fearful',
|
||||
icon: IconEmotionsFearful
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.angry',
|
||||
icon: IconEmotionsAngry
|
||||
},
|
||||
{
|
||||
translationKey: 'speakerStats.disgusted',
|
||||
icon: IconEmotionsDisgusted
|
||||
}
|
||||
];
|
||||
|
||||
const SpeakerStats = () => {
|
||||
const { faceLandmarks } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { showFaceExpressions } = useSelector((state: IReduxState) => state['features/speaker-stats']);
|
||||
const { videoSpaceWidth } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
|
||||
const displaySwitch = faceLandmarks?.enableDisplayFaceExpressions && videoSpaceWidth > DISPLAY_SWITCH_BREAKPOINT;
|
||||
const displayLabels = videoSpaceWidth > MOBILE_BREAKPOINT;
|
||||
const dispatch = useDispatch();
|
||||
const { classes } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onToggleFaceExpressions = useCallback(() =>
|
||||
dispatch(toggleFaceExpressions())
|
||||
, [ dispatch ]);
|
||||
|
||||
const onSearch = useCallback((criteria = '') => {
|
||||
dispatch(initSearch(escapeRegexp(criteria)));
|
||||
}
|
||||
, [ dispatch ]);
|
||||
|
||||
useEffect(() => {
|
||||
showFaceExpressions && !displaySwitch && dispatch(toggleFaceExpressions());
|
||||
}, [ videoSpaceWidth ]);
|
||||
|
||||
useEffect(() => () => {
|
||||
dispatch(resetSearchCriteria());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
ok = {{ hidden: true }}
|
||||
size = { showFaceExpressions ? 'large' : 'medium' }
|
||||
titleKey = 'speakerStats.speakerStats'>
|
||||
<div className = { classes.speakerStats }>
|
||||
<div className = { `header ${showFaceExpressions ? 'large' : 'medium'}` }>
|
||||
<div className = 'upper-header'>
|
||||
<div
|
||||
className = {
|
||||
`search-switch-container
|
||||
${showFaceExpressions ? 'expressions-on' : ''}`
|
||||
}>
|
||||
<div
|
||||
className = {
|
||||
displaySwitch
|
||||
? 'search-container'
|
||||
: 'search-container-full-width' }>
|
||||
<SpeakerStatsSearch
|
||||
onSearch = { onSearch } />
|
||||
</div>
|
||||
|
||||
{ displaySwitch
|
||||
&& <FaceExpressionsSwitch
|
||||
onChange = { onToggleFaceExpressions }
|
||||
showFaceExpressions = { showFaceExpressions } />
|
||||
|
||||
}
|
||||
</div>
|
||||
{ showFaceExpressions && <div className = 'emotions-icons'>
|
||||
{
|
||||
EMOTIONS_LEGEND.map(emotion => (
|
||||
<Tooltip
|
||||
content = { t(emotion.translationKey) }
|
||||
key = { emotion.translationKey }
|
||||
position = { 'top' }>
|
||||
<Icon
|
||||
size = { 20 }
|
||||
src = { emotion.icon } />
|
||||
</Tooltip>
|
||||
))
|
||||
}
|
||||
</div>}
|
||||
</div>
|
||||
{ displayLabels && (
|
||||
<SpeakerStatsLabels
|
||||
showFaceExpressions = { showFaceExpressions ?? false } />
|
||||
)}
|
||||
</div>
|
||||
<SpeakerStatsList />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStats;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { isSpeakerStatsDisabled } from '../../functions';
|
||||
import AbstractSpeakerStatsButton from '../AbstractSpeakerStatsButton';
|
||||
|
||||
import SpeakerStats from './SpeakerStats';
|
||||
|
||||
|
||||
/**
|
||||
* Implementation of a button for opening speaker stats dialog.
|
||||
*/
|
||||
class SpeakerStatsButton extends AbstractSpeakerStatsButton {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('speaker.stats'));
|
||||
dispatch(openDialog(SpeakerStats));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that maps parts of Redux state tree into component props.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
const mapStateToProps = (state: IReduxState) => {
|
||||
return {
|
||||
visible: !isSpeakerStatsDisabled(state)
|
||||
};
|
||||
};
|
||||
|
||||
export default translate(connect(mapStateToProps)(SpeakerStatsButton));
|
||||
116
react/features/speaker-stats/components/web/SpeakerStatsItem.tsx
Normal file
116
react/features/speaker-stats/components/web/SpeakerStatsItem.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
import React from 'react';
|
||||
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import StatelessAvatar from '../../../base/avatar/components/web/StatelessAvatar';
|
||||
import { getInitials } from '../../../base/avatar/functions';
|
||||
import { IconUser } from '../../../base/icons/svg';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
|
||||
import { FaceLandmarks } from '../../../face-landmarks/types';
|
||||
|
||||
import TimeElapsed from './TimeElapsed';
|
||||
import Timeline from './Timeline';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SpeakerStatsItem}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The name of the participant.
|
||||
*/
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* The total milliseconds the participant has been dominant speaker.
|
||||
*/
|
||||
dominantSpeakerTime: number;
|
||||
|
||||
/**
|
||||
* The object that has as keys the face expressions of the
|
||||
* participant and as values a number that represents the count .
|
||||
*/
|
||||
faceLandmarks?: FaceLandmarks[];
|
||||
|
||||
/**
|
||||
* True if the participant is no longer in the meeting.
|
||||
*/
|
||||
hasLeft: boolean;
|
||||
|
||||
/**
|
||||
* True if the participant is not shown in speaker stats.
|
||||
*/
|
||||
hidden: boolean;
|
||||
|
||||
/**
|
||||
* True if the participant is currently the dominant speaker.
|
||||
*/
|
||||
isDominantSpeaker: boolean;
|
||||
|
||||
/**
|
||||
* The id of the user.
|
||||
*/
|
||||
participantId: string;
|
||||
|
||||
/**
|
||||
* True if the face expressions detection is not disabled.
|
||||
*/
|
||||
showFaceExpressions: boolean;
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
const SpeakerStatsItem = (props: IProps) => {
|
||||
const rowDisplayClass = `row item ${props.hasLeft ? 'has-left' : ''}`;
|
||||
const nameTimeClass = `name-time${
|
||||
props.showFaceExpressions ? ' expressions-on' : ''
|
||||
}`;
|
||||
const timeClass = `time ${props.isDominantSpeaker ? 'dominant' : ''}`;
|
||||
|
||||
return (
|
||||
<div key = { props.participantId }>
|
||||
<div className = { rowDisplayClass } >
|
||||
<div className = 'avatar' >
|
||||
{
|
||||
props.hasLeft ? (
|
||||
<StatelessAvatar
|
||||
className = 'userAvatar'
|
||||
color = { BaseTheme.palette.ui04 }
|
||||
iconUser = { IconUser }
|
||||
initials = { getInitials(props.displayName) }
|
||||
size = { 32 } />
|
||||
) : (
|
||||
<Avatar
|
||||
className = 'userAvatar'
|
||||
participantId = { props.participantId }
|
||||
size = { 32 } />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className = { nameTimeClass }>
|
||||
<div
|
||||
aria-label = { props.t('speakerStats.speakerStats') }
|
||||
className = 'display-name'>
|
||||
{ props.displayName }
|
||||
</div>
|
||||
<div
|
||||
aria-label = { props.t('speakerStats.speakerTime') }
|
||||
className = { timeClass }>
|
||||
<TimeElapsed
|
||||
time = { props.dominantSpeakerTime } />
|
||||
</div>
|
||||
</div>
|
||||
{ props.showFaceExpressions
|
||||
&& <Timeline faceLandmarks = { props.faceLandmarks } />
|
||||
}
|
||||
|
||||
</div>
|
||||
<div className = 'separator' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStatsItem;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import TimelineAxis from './TimelineAxis';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
labels: {
|
||||
padding: '22px 0 7px 0',
|
||||
height: 20,
|
||||
'& .avatar-placeholder': {
|
||||
width: '32px',
|
||||
marginRight: theme.spacing(3)
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SpeakerStatsLabels}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* True if the face expressions detection is not disabled.
|
||||
*/
|
||||
showFaceExpressions: boolean;
|
||||
}
|
||||
|
||||
const SpeakerStatsLabels = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
const nameTimeClass = `name-time${
|
||||
props.showFaceExpressions ? ' expressions-on' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className = { `row ${classes.labels}` }>
|
||||
<div className = 'avatar-placeholder' />
|
||||
|
||||
<div className = { nameTimeClass }>
|
||||
<div>
|
||||
{ t('speakerStats.name') }
|
||||
</div>
|
||||
<div>
|
||||
{ t('speakerStats.speakerTime') }
|
||||
</div>
|
||||
</div>
|
||||
{props.showFaceExpressions && <TimelineAxis />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStatsLabels;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { MOBILE_BREAKPOINT } from '../../constants';
|
||||
import abstractSpeakerStatsList from '../AbstractSpeakerStatsList';
|
||||
|
||||
import SpeakerStatsItem from './SpeakerStatsItem';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
list: {
|
||||
paddingTop: 90,
|
||||
'& .item': {
|
||||
height: theme.spacing(7),
|
||||
[theme.breakpoints.down(MOBILE_BREAKPOINT)]: {
|
||||
height: theme.spacing(8)
|
||||
},
|
||||
'& .has-left': {
|
||||
color: theme.palette.text03
|
||||
},
|
||||
'& .avatar': {
|
||||
marginRight: theme.spacing(3)
|
||||
},
|
||||
'& .time': {
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
...theme.typography.labelBold,
|
||||
[theme.breakpoints.down(MOBILE_BREAKPOINT)]: {
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
},
|
||||
backgroundColor: theme.palette.ui02
|
||||
},
|
||||
'& .display-name': {
|
||||
...theme.typography.bodyShortRegular,
|
||||
[theme.breakpoints.down(MOBILE_BREAKPOINT)]: {
|
||||
...theme.typography.bodyShortRegularLarge
|
||||
}
|
||||
},
|
||||
'& .dominant': {
|
||||
backgroundColor: theme.palette.success02
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders the list of speaker stats.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SpeakerStatsList = () => {
|
||||
const { classes } = useStyles();
|
||||
const items = abstractSpeakerStatsList(SpeakerStatsItem);
|
||||
|
||||
return (
|
||||
<div className = { classes.list }>
|
||||
<div className = 'separator' />
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerStatsList;
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconSearch } from '../../../base/icons/svg';
|
||||
import { getFieldValue } from '../../../base/react/functions';
|
||||
import { HiddenDescription } from '../../../base/ui/components/web/HiddenDescription';
|
||||
import { MOBILE_BREAKPOINT } from '../../constants';
|
||||
import { isSpeakerStatsSearchDisabled } from '../../functions';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
speakerStatsSearchContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
searchIcon: {
|
||||
display: 'none',
|
||||
[theme.breakpoints.down(MOBILE_BREAKPOINT)]: {
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
color: theme.palette.text03,
|
||||
left: 16,
|
||||
top: 13,
|
||||
width: 20,
|
||||
height: 20
|
||||
}
|
||||
},
|
||||
speakerStatsSearch: {
|
||||
backgroundColor: theme.palette.field01,
|
||||
border: '1px solid',
|
||||
borderRadius: 6,
|
||||
borderColor: theme.palette.ui05,
|
||||
color: theme.palette.text01,
|
||||
padding: '10px 16px',
|
||||
width: '100%',
|
||||
height: 40,
|
||||
'&::placeholder': {
|
||||
color: theme.palette.text03,
|
||||
...theme.typography.bodyShortRegular
|
||||
},
|
||||
[theme.breakpoints.down(MOBILE_BREAKPOINT)]: {
|
||||
height: 48,
|
||||
padding: '13px 16px 13px 44px',
|
||||
'&::placeholder': {
|
||||
...theme.typography.bodyShortRegular
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SpeakerStatsSearch}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The function to initiate the change in the speaker stats table.
|
||||
*/
|
||||
onSearch: Function;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* React component for display an individual user's speaker stats.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function SpeakerStatsSearch({ onSearch }: IProps) {
|
||||
const { classes, theme } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const disableSpeakerStatsSearch = useSelector(isSpeakerStatsSearchDisabled);
|
||||
const [ searchValue, setSearchValue ] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Callback for the onChange event of the field.
|
||||
*
|
||||
* @param {Object} evt - The static event.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onChange = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = getFieldValue(evt);
|
||||
|
||||
setSearchValue(value);
|
||||
onSearch?.(value);
|
||||
}, []);
|
||||
const preventDismiss = useCallback((evt: React.KeyboardEvent) => {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (disableSpeakerStatsSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputId = 'speaker-stats-search';
|
||||
const inputDescriptionId = `${inputId}-hidden-description`;
|
||||
|
||||
return (
|
||||
<div className = { classes.speakerStatsSearchContainer }>
|
||||
<Icon
|
||||
className = { classes.searchIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSearch } />
|
||||
<input
|
||||
aria-describedby = { inputDescriptionId }
|
||||
aria-label = { t('speakerStats.searchHint') }
|
||||
autoComplete = 'off'
|
||||
autoFocus = { false }
|
||||
className = { classes.speakerStatsSearch }
|
||||
id = { inputId }
|
||||
name = 'speakerStatsSearch'
|
||||
onChange = { onChange }
|
||||
onKeyPress = { preventDismiss }
|
||||
placeholder = { t('speakerStats.search') }
|
||||
tabIndex = { 0 }
|
||||
value = { searchValue } />
|
||||
<HiddenDescription id = { inputDescriptionId }>
|
||||
{t('speakerStats.searchDescription')}
|
||||
</HiddenDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpeakerStatsSearch;
|
||||
36
react/features/speaker-stats/components/web/TimeElapsed.tsx
Normal file
36
react/features/speaker-stats/components/web/TimeElapsed.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { createLocalizedTime } from '../timeFunctions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link TimeElapsed}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The milliseconds to be converted into a human-readable format.
|
||||
*/
|
||||
time: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React component for displaying total time elapsed. Converts a total count of
|
||||
* milliseconds into a more humanized form: "# hours, # minutes, # seconds".
|
||||
* With a time of 0, "0s" will be displayed.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
|
||||
const TimeElapsed = ({ time }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const timeElapsed = createLocalizedTime(time, t);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{ timeElapsed }
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeElapsed;
|
||||
207
react/features/speaker-stats/components/web/Timeline.tsx
Normal file
207
react/features/speaker-stats/components/web/Timeline.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { getConferenceTimestamp } from '../../../base/conference/functions';
|
||||
import { FaceLandmarks } from '../../../face-landmarks/types';
|
||||
import { addToOffset, setTimelinePanning } from '../../actions.any';
|
||||
import { SCROLL_RATE, TIMELINE_COLORS } from '../../constants';
|
||||
import { getFaceLandmarksEnd, getFaceLandmarksStart, getTimelineBoundaries } from '../../functions';
|
||||
|
||||
interface IProps {
|
||||
faceLandmarks?: FaceLandmarks[];
|
||||
}
|
||||
|
||||
const Timeline = ({ faceLandmarks }: IProps) => {
|
||||
const startTimestamp = useSelector((state: IReduxState) => getConferenceTimestamp(state)) ?? 0;
|
||||
const { left, right } = useSelector((state: IReduxState) => getTimelineBoundaries(state));
|
||||
const { timelinePanning } = useSelector((state: IReduxState) => state['features/speaker-stats']);
|
||||
const dispatch = useDispatch();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const intervalDuration = useMemo(() => right - left, [ left, right ]);
|
||||
|
||||
const getSegments = useCallback(() => {
|
||||
const segments = faceLandmarks?.filter(landmarks => {
|
||||
const timeStart = getFaceLandmarksStart(landmarks, startTimestamp);
|
||||
const timeEnd = getFaceLandmarksEnd(landmarks, startTimestamp);
|
||||
|
||||
if (timeEnd > left && timeStart < right) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}) ?? [];
|
||||
|
||||
let leftCut;
|
||||
let rightCut;
|
||||
|
||||
if (segments.length) {
|
||||
const start = getFaceLandmarksStart(segments[0], startTimestamp);
|
||||
const end = getFaceLandmarksEnd(segments[segments.length - 1], startTimestamp);
|
||||
|
||||
if (start <= left) {
|
||||
leftCut = segments[0];
|
||||
}
|
||||
if (end >= right) {
|
||||
rightCut = segments[segments.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
if (leftCut) {
|
||||
segments.shift();
|
||||
}
|
||||
if (rightCut) {
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
segments,
|
||||
leftCut,
|
||||
rightCut
|
||||
};
|
||||
}, [ faceLandmarks, left, right, startTimestamp ]);
|
||||
|
||||
const { segments, leftCut, rightCut } = getSegments();
|
||||
|
||||
const getStyle = useCallback((duration: number, faceExpression: string) => {
|
||||
return {
|
||||
width: `${100 / (intervalDuration / duration)}%`,
|
||||
backgroundColor: TIMELINE_COLORS[faceExpression] ?? TIMELINE_COLORS['no-detection']
|
||||
};
|
||||
}, [ intervalDuration ]);
|
||||
|
||||
|
||||
const getStartStyle = useCallback(() => {
|
||||
let startDuration = 0;
|
||||
let color = TIMELINE_COLORS['no-detection'];
|
||||
|
||||
if (leftCut) {
|
||||
const { faceExpression } = leftCut;
|
||||
|
||||
startDuration = getFaceLandmarksEnd(leftCut, startTimestamp) - left;
|
||||
color = TIMELINE_COLORS[faceExpression];
|
||||
} else if (segments.length) {
|
||||
startDuration = getFaceLandmarksStart(segments[0], startTimestamp) - left;
|
||||
} else if (rightCut) {
|
||||
startDuration = getFaceLandmarksStart(rightCut, startTimestamp) - left;
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${100 / (intervalDuration / startDuration)}%`,
|
||||
backgroundColor: color
|
||||
};
|
||||
}, [ leftCut, rightCut, startTimestamp, left, intervalDuration, segments ]);
|
||||
|
||||
const getEndStyle = useCallback(() => {
|
||||
let endDuration = 0;
|
||||
let color = TIMELINE_COLORS['no-detection'];
|
||||
|
||||
if (rightCut) {
|
||||
const { faceExpression } = rightCut;
|
||||
|
||||
endDuration = right - getFaceLandmarksStart(rightCut, startTimestamp);
|
||||
color = TIMELINE_COLORS[faceExpression];
|
||||
} else if (segments.length) {
|
||||
endDuration = right - getFaceLandmarksEnd(segments[segments.length - 1], startTimestamp);
|
||||
} else if (leftCut) {
|
||||
endDuration = right - getFaceLandmarksEnd(leftCut, startTimestamp);
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${100 / (intervalDuration / endDuration)}%`,
|
||||
backgroundColor: color
|
||||
};
|
||||
}, [ leftCut, rightCut, startTimestamp, right, intervalDuration, segments ]);
|
||||
|
||||
const getOneSegmentStyle = useCallback((faceExpression?: string) => {
|
||||
return {
|
||||
width: '100%',
|
||||
backgroundColor: faceExpression ? TIMELINE_COLORS[faceExpression] : TIMELINE_COLORS['no-detection'],
|
||||
borderRadius: 0
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOnWheel = useCallback((event: WheelEvent) => {
|
||||
// check if horizontal scroll
|
||||
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
||||
const value = event.deltaX * SCROLL_RATE;
|
||||
|
||||
dispatch(addToOffset(value));
|
||||
event.preventDefault();
|
||||
}
|
||||
}, [ dispatch, addToOffset ]);
|
||||
|
||||
const hideStartAndEndSegments = useCallback(() => leftCut && rightCut
|
||||
&& leftCut.faceExpression === rightCut.faceExpression
|
||||
&& !segments.length,
|
||||
[ leftCut, rightCut, segments ]);
|
||||
|
||||
useEffect(() => {
|
||||
containerRef.current?.addEventListener('wheel', handleOnWheel, { passive: false });
|
||||
|
||||
return () => containerRef.current?.removeEventListener('wheel', handleOnWheel);
|
||||
}, []);
|
||||
|
||||
const getPointOnTimeline = useCallback((event: MouseEvent) => {
|
||||
const axisRect = event.currentTarget.getBoundingClientRect();
|
||||
const eventOffsetX = event.pageX - axisRect.left;
|
||||
|
||||
return (eventOffsetX * right) / axisRect.width;
|
||||
}, [ right ]);
|
||||
|
||||
|
||||
const handleOnMouseMove = useCallback((event: MouseEvent) => {
|
||||
const { active, x } = timelinePanning;
|
||||
|
||||
if (active) {
|
||||
const point = getPointOnTimeline(event);
|
||||
|
||||
dispatch(addToOffset(x - point));
|
||||
dispatch(setTimelinePanning({ ...timelinePanning,
|
||||
x: point }));
|
||||
}
|
||||
}, [ timelinePanning, dispatch, addToOffset, setTimelinePanning, getPointOnTimeline ]);
|
||||
|
||||
const handleOnMouseDown = useCallback((event: MouseEvent) => {
|
||||
const point = getPointOnTimeline(event);
|
||||
|
||||
dispatch(setTimelinePanning(
|
||||
{
|
||||
active: true,
|
||||
x: point
|
||||
}
|
||||
));
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, [ getPointOnTimeline, dispatch, setTimelinePanning ]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = 'timeline-container'
|
||||
onMouseDown = { handleOnMouseDown }
|
||||
onMouseMove = { handleOnMouseMove }
|
||||
ref = { containerRef }>
|
||||
<div
|
||||
className = 'timeline'>
|
||||
{!hideStartAndEndSegments() && <div
|
||||
aria-label = 'start'
|
||||
style = { getStartStyle() } />}
|
||||
{hideStartAndEndSegments() && <div
|
||||
style = { getOneSegmentStyle(leftCut?.faceExpression) } />}
|
||||
{segments?.map(({ duration, timestamp, faceExpression }) =>
|
||||
(<div
|
||||
aria-label = { faceExpression }
|
||||
key = { timestamp }
|
||||
style = { getStyle(duration, faceExpression) } />)) }
|
||||
|
||||
{!hideStartAndEndSegments() && <div
|
||||
aria-label = 'end'
|
||||
style = { getEndStyle() } />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
187
react/features/speaker-stats/components/web/TimelineAxis.tsx
Normal file
187
react/features/speaker-stats/components/web/TimelineAxis.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { addToOffset, addToOffsetLeft, addToOffsetRight, setTimelinePanning } from '../../actions.any';
|
||||
import { MIN_HANDLER_WIDTH } from '../../constants';
|
||||
import { getCurrentDuration, getTimelineBoundaries } from '../../functions';
|
||||
|
||||
import TimeElapsed from './TimeElapsed';
|
||||
|
||||
const TimelineAxis = () => {
|
||||
const currentDuration = useSelector((state: IReduxState) => getCurrentDuration(state)) ?? 0;
|
||||
const { left, right } = useSelector((state: IReduxState) => getTimelineBoundaries(state));
|
||||
const { timelinePanning } = useSelector((state: IReduxState) => state['features/speaker-stats']);
|
||||
const dispatch = useDispatch();
|
||||
const axisRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [ dragLeft, setDragLeft ] = useState(false);
|
||||
const [ dragRight, setDragRight ] = useState(false);
|
||||
|
||||
const getPointOnAxis = useCallback((event: MouseEvent) => {
|
||||
const axisRect = event.currentTarget.getBoundingClientRect();
|
||||
const eventOffsetX = event.pageX - axisRect.left;
|
||||
|
||||
return (eventOffsetX * currentDuration) / axisRect.width;
|
||||
}, [ currentDuration ]);
|
||||
|
||||
const startResizeHandlerLeft = useCallback((event: MouseEvent) => {
|
||||
if (!timelinePanning.active && !dragRight) {
|
||||
setDragLeft(true);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, [ dragRight, timelinePanning, setDragLeft ]);
|
||||
|
||||
const stopResizeLeft = () => {
|
||||
setDragLeft(false);
|
||||
};
|
||||
|
||||
const resizeHandlerLeft = useCallback((event: MouseEvent) => {
|
||||
if (dragLeft) {
|
||||
const point = getPointOnAxis(event);
|
||||
|
||||
if (point >= 0 && point < right) {
|
||||
const value = point - left;
|
||||
|
||||
dispatch(addToOffsetLeft(value));
|
||||
}
|
||||
}
|
||||
}, [ dragLeft, getPointOnAxis, dispatch, addToOffsetLeft ]);
|
||||
|
||||
const startResizeHandlerRight = useCallback((event: MouseEvent) => {
|
||||
if (!timelinePanning.active && !dragRight) {
|
||||
setDragRight(true);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, [ timelinePanning, dragRight ]);
|
||||
|
||||
const stopResizeRight = useCallback(() => {
|
||||
setDragRight(false);
|
||||
}, [ setDragRight ]);
|
||||
|
||||
const resizeHandlerRight = (event: MouseEvent) => {
|
||||
if (dragRight) {
|
||||
const point = getPointOnAxis(event);
|
||||
|
||||
if (point > left && point <= currentDuration) {
|
||||
const value = point - right;
|
||||
|
||||
dispatch(addToOffsetRight(value));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startMoveHandler = useCallback((event: MouseEvent) => {
|
||||
if (!dragLeft && !dragRight) {
|
||||
const point = getPointOnAxis(event);
|
||||
|
||||
dispatch(setTimelinePanning(
|
||||
{
|
||||
active: true,
|
||||
x: point
|
||||
}
|
||||
));
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, [ dragLeft, dragRight, getPointOnAxis, dispatch, setTimelinePanning ]);
|
||||
|
||||
const stopMoveHandler = () => {
|
||||
dispatch(setTimelinePanning({ ...timelinePanning,
|
||||
active: false }));
|
||||
};
|
||||
|
||||
const moveHandler = useCallback((event: MouseEvent) => {
|
||||
const { active, x } = timelinePanning;
|
||||
|
||||
if (active) {
|
||||
const point = getPointOnAxis(event);
|
||||
|
||||
dispatch(addToOffset(point - x));
|
||||
dispatch(setTimelinePanning({ ...timelinePanning,
|
||||
x: point }));
|
||||
}
|
||||
}, [ timelinePanning, getPointOnAxis, dispatch, addToOffset, setTimelinePanning ]);
|
||||
|
||||
const handleOnMouseMove = useCallback((event: MouseEvent) => {
|
||||
resizeHandlerLeft(event);
|
||||
resizeHandlerRight(event);
|
||||
moveHandler(event);
|
||||
}, [ resizeHandlerLeft, resizeHandlerRight ]);
|
||||
|
||||
const handleOnMouseUp = useCallback(() => {
|
||||
stopResizeLeft();
|
||||
stopResizeRight();
|
||||
stopMoveHandler();
|
||||
}, [ stopResizeLeft, stopResizeRight, stopMoveHandler ]);
|
||||
|
||||
const getHandlerStyle = useCallback(() => {
|
||||
let marginLeft = 100 / (currentDuration / left);
|
||||
let width = 100 / (currentDuration / (right - left));
|
||||
|
||||
if (axisRef.current) {
|
||||
const axisWidth = axisRef.current.getBoundingClientRect().width;
|
||||
let handlerWidth = (width / 100) * axisWidth;
|
||||
|
||||
if (handlerWidth < MIN_HANDLER_WIDTH) {
|
||||
const newLeft = right - ((currentDuration * MIN_HANDLER_WIDTH) / axisWidth);
|
||||
|
||||
handlerWidth = MIN_HANDLER_WIDTH;
|
||||
marginLeft = 100 / (currentDuration / newLeft);
|
||||
width = 100 / (currentDuration / (right - newLeft));
|
||||
}
|
||||
|
||||
if (marginLeft + width > 100) {
|
||||
return {
|
||||
marginLeft: `calc(100% - ${handlerWidth}px)`,
|
||||
width: handlerWidth
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
marginLeft: `${marginLeft > 0 ? marginLeft : 0}%`,
|
||||
width: `${width}%`
|
||||
};
|
||||
}, [ currentDuration, left, right, axisRef ]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', handleOnMouseUp);
|
||||
|
||||
return () => window.removeEventListener('mouseup', handleOnMouseUp);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = 'axis-container'
|
||||
onMouseMove = { handleOnMouseMove }
|
||||
ref = { axisRef }>
|
||||
<div
|
||||
className = 'axis'>
|
||||
<div className = 'left-bound'>
|
||||
<TimeElapsed time = { 0 } />
|
||||
</div>
|
||||
<div className = 'right-bound'>
|
||||
<TimeElapsed time = { currentDuration } />
|
||||
</div>
|
||||
<div
|
||||
className = 'handler'
|
||||
onMouseDown = { startMoveHandler }
|
||||
style = { getHandlerStyle() } >
|
||||
<div
|
||||
className = 'resize'
|
||||
id = 'left'
|
||||
onMouseDown = { startResizeHandlerLeft } />
|
||||
<div
|
||||
className = 'resize'
|
||||
id = 'right'
|
||||
onMouseDown = { startResizeHandlerRight } />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineAxis;
|
||||
28
react/features/speaker-stats/constants.ts
Normal file
28
react/features/speaker-stats/constants.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const SPEAKER_STATS_RELOAD_INTERVAL = 1000;
|
||||
|
||||
export const DISPLAY_SWITCH_BREAKPOINT = 600;
|
||||
|
||||
export const MOBILE_BREAKPOINT = 480;
|
||||
|
||||
export const THRESHOLD_FIXED_AXIS = 10000;
|
||||
|
||||
export const MINIMUM_INTERVAL = 4000;
|
||||
|
||||
export const SCROLL_RATE = 500;
|
||||
|
||||
export const MIN_HANDLER_WIDTH = 30;
|
||||
|
||||
export const TIMELINE_COLORS: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
happy: '#F3AD26',
|
||||
neutral: '#676767',
|
||||
sad: '#539EF9',
|
||||
surprised: '#BC72E1',
|
||||
angry: '#F35826',
|
||||
fearful: '#3AC8C8',
|
||||
disgusted: '#65B16B',
|
||||
'no-detection': '#FFFFFF00'
|
||||
};
|
||||
|
||||
export const CLEAR_TIME_BOUNDARY_THRESHOLD = 1000;
|
||||
274
react/features/speaker-stats/functions.ts
Normal file
274
react/features/speaker-stats/functions.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
import { getConferenceTimestamp } from '../base/conference/functions';
|
||||
import { PARTICIPANT_ROLE } from '../base/participants/constants';
|
||||
import { getParticipantById } from '../base/participants/functions';
|
||||
import { FaceLandmarks } from '../face-landmarks/types';
|
||||
|
||||
import { THRESHOLD_FIXED_AXIS } from './constants';
|
||||
import { ISpeaker, ISpeakerStats } from './reducer';
|
||||
|
||||
/**
|
||||
* Checks if the speaker stats search is disabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - True if the speaker stats search is disabled and false otherwise.
|
||||
*/
|
||||
export function isSpeakerStatsSearchDisabled(state: IReduxState) {
|
||||
return state['features/base/config']?.speakerStats?.disableSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the speaker stats is disabled.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - True if the speaker stats search is disabled and false otherwise.
|
||||
*/
|
||||
export function isSpeakerStatsDisabled(state: IReduxState) {
|
||||
return state['features/base/config']?.speakerStats?.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets whether participants in speaker stats should be ordered or not, and with what priority.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Array<string>} - The speaker stats order array or an empty array.
|
||||
*/
|
||||
export function getSpeakerStatsOrder(state: IReduxState) {
|
||||
return state['features/base/config']?.speakerStats?.order ?? [
|
||||
'role',
|
||||
'name',
|
||||
'hasLeft'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets speaker stats.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {Object} - The speaker stats.
|
||||
*/
|
||||
export function getSpeakerStats(state: IReduxState) {
|
||||
return state['features/speaker-stats']?.stats ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets speaker stats search criteria.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {string | null} - The search criteria.
|
||||
*/
|
||||
export function getSearchCriteria(state: IReduxState) {
|
||||
return state['features/speaker-stats']?.criteria;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets if speaker stats reorder is pending.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - The pending reorder flag.
|
||||
*/
|
||||
export function getPendingReorder(state: IReduxState) {
|
||||
return state['features/speaker-stats']?.pendingReorder ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted speaker stats ids based on a configuration setting.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @param {IState} stats - The current speaker stats.
|
||||
* @returns {string[] | undefined} - Ordered speaker stats ids.
|
||||
* @public
|
||||
*/
|
||||
export function getSortedSpeakerStatsIds(state: IReduxState, stats: ISpeakerStats) {
|
||||
const orderConfig = getSpeakerStatsOrder(state);
|
||||
|
||||
if (orderConfig) {
|
||||
const enhancedStats = getEnhancedStatsForOrdering(state, stats, orderConfig);
|
||||
|
||||
return Object.entries(enhancedStats)
|
||||
.sort(([ , a ], [ , b ]) => compareFn(a, b))
|
||||
.map(el => el[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Compares the order of two participants in the speaker stats list.
|
||||
*
|
||||
* @param {ISpeaker} currentParticipant - The first participant for comparison.
|
||||
* @param {ISpeaker} nextParticipant - The second participant for comparison.
|
||||
* @returns {number} - The sort order of the two participants.
|
||||
*/
|
||||
function compareFn(currentParticipant: ISpeaker, nextParticipant: ISpeaker) {
|
||||
if (orderConfig.includes('hasLeft')) {
|
||||
if (nextParticipant.hasLeft() && !currentParticipant.hasLeft()) {
|
||||
return -1;
|
||||
} else if (currentParticipant.hasLeft() && !nextParticipant.hasLeft()) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
|
||||
for (const sortCriteria of orderConfig) {
|
||||
switch (sortCriteria) {
|
||||
case 'role':
|
||||
if (!nextParticipant.isModerator && currentParticipant.isModerator) {
|
||||
result = -1;
|
||||
} else if (!currentParticipant.isModerator && nextParticipant.isModerator) {
|
||||
result = 1;
|
||||
} else {
|
||||
result = 0;
|
||||
}
|
||||
break;
|
||||
case 'name':
|
||||
result = (currentParticipant.displayName || '').localeCompare(
|
||||
nextParticipant.displayName || ''
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance speaker stats to include data needed for ordering.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @param {ISpeakerStats} stats - Speaker stats.
|
||||
* @param {Array<string>} orderConfig - Ordering configuration.
|
||||
* @returns {ISpeakerStats} - Enhanced speaker stats.
|
||||
* @public
|
||||
*/
|
||||
function getEnhancedStatsForOrdering(state: IReduxState, stats: ISpeakerStats, orderConfig: Array<string>) {
|
||||
if (!orderConfig) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
for (const id in stats) {
|
||||
if (stats[id].hasOwnProperty('_hasLeft') && !stats[id].hasLeft()) {
|
||||
if (orderConfig.includes('role')) {
|
||||
const participant = getParticipantById(state, stats[id].getUserId());
|
||||
|
||||
stats[id].isModerator = participant && participant.role === PARTICIPANT_ROLE.MODERATOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter stats by search criteria.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @param {ISpeakerStats | undefined} stats - The unfiltered stats.
|
||||
*
|
||||
* @returns {ISpeakerStats} - Filtered speaker stats.
|
||||
* @public
|
||||
*/
|
||||
export function filterBySearchCriteria(state: IReduxState, stats?: ISpeakerStats) {
|
||||
const filteredStats = cloneDeep(stats ?? getSpeakerStats(state));
|
||||
const criteria = getSearchCriteria(state);
|
||||
|
||||
if (criteria !== null) {
|
||||
const searchRegex = new RegExp(criteria, 'gi');
|
||||
|
||||
for (const id in filteredStats) {
|
||||
if (filteredStats[id].hasOwnProperty('_isLocalStats')) {
|
||||
const name = filteredStats[id].getDisplayName();
|
||||
|
||||
filteredStats[id].hidden = !name?.match(searchRegex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the hidden speaker stats.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @param {ISpeakerStats | undefined} stats - The unfiltered stats.
|
||||
*
|
||||
* @returns {Object} - Speaker stats.
|
||||
* @public
|
||||
*/
|
||||
export function resetHiddenStats(state: IReduxState, stats?: ISpeakerStats) {
|
||||
const resetStats = cloneDeep(stats ?? getSpeakerStats(state));
|
||||
|
||||
for (const id in resetStats) {
|
||||
if (resetStats[id].hidden) {
|
||||
resetStats[id].hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
return resetStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current duration of the conference.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @returns {number | null} - The duration in milliseconds or null.
|
||||
*/
|
||||
export function getCurrentDuration(state: IReduxState) {
|
||||
const startTimestamp = getConferenceTimestamp(state);
|
||||
|
||||
return startTimestamp ? Date.now() - startTimestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the boundaries of the emotion timeline.
|
||||
*
|
||||
* @param {IState} state - The redux state.
|
||||
* @returns {Object} - The left and right boundaries.
|
||||
*/
|
||||
export function getTimelineBoundaries(state: IReduxState) {
|
||||
const { timelineBoundary, offsetLeft, offsetRight } = state['features/speaker-stats'];
|
||||
const currentDuration = getCurrentDuration(state) ?? 0;
|
||||
const rightBoundary = timelineBoundary ? timelineBoundary : currentDuration;
|
||||
let leftOffset = 0;
|
||||
|
||||
if (rightBoundary > THRESHOLD_FIXED_AXIS) {
|
||||
leftOffset = rightBoundary - THRESHOLD_FIXED_AXIS;
|
||||
}
|
||||
|
||||
const left = offsetLeft + leftOffset;
|
||||
const right = rightBoundary + offsetRight;
|
||||
|
||||
return {
|
||||
left,
|
||||
right
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the conference start time of the face landmarks.
|
||||
*
|
||||
* @param {FaceLandmarks} faceLandmarks - The face landmarks.
|
||||
* @param {number} startTimestamp - The start timestamp of the conference.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getFaceLandmarksStart(faceLandmarks: FaceLandmarks, startTimestamp: number) {
|
||||
return faceLandmarks.timestamp - startTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the conference end time of the face landmarks.
|
||||
*
|
||||
* @param {FaceLandmarks} faceLandmarks - The face landmarks.
|
||||
* @param {number} startTimestamp - The start timestamp of the conference.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getFaceLandmarksEnd(faceLandmarks: FaceLandmarks, startTimestamp: number) {
|
||||
return getFaceLandmarksStart(faceLandmarks, startTimestamp) + faceLandmarks.duration;
|
||||
}
|
||||
23
react/features/speaker-stats/hooks.web.ts
Normal file
23
react/features/speaker-stats/hooks.web.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import SpeakerStatsButton from './components/web/SpeakerStatsButton';
|
||||
import { isSpeakerStatsDisabled } from './functions';
|
||||
|
||||
const speakerStats = {
|
||||
key: 'stats',
|
||||
Content: SpeakerStatsButton,
|
||||
group: 3
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the speaker stats button if it is enabled and undefined otherwise.
|
||||
*
|
||||
* @returns {Object | undefined}
|
||||
*/
|
||||
export function useSpeakerStatsButton() {
|
||||
const disabled = useSelector(isSpeakerStatsDisabled);
|
||||
|
||||
if (!disabled) {
|
||||
return speakerStats;
|
||||
}
|
||||
}
|
||||
101
react/features/speaker-stats/middleware.ts
Normal file
101
react/features/speaker-stats/middleware.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { batch } from 'react-redux';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import {
|
||||
PARTICIPANT_JOINED,
|
||||
PARTICIPANT_KICKED,
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_UPDATED
|
||||
} from '../base/participants/actionTypes';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
|
||||
import {
|
||||
ADD_TO_OFFSET,
|
||||
INIT_SEARCH,
|
||||
INIT_UPDATE_STATS,
|
||||
RESET_SEARCH_CRITERIA
|
||||
} from './actionTypes';
|
||||
import {
|
||||
clearTimelineBoundary,
|
||||
initReorderStats,
|
||||
setTimelineBoundary,
|
||||
updateSortedSpeakerStatsIds,
|
||||
updateStats
|
||||
} from './actions.any';
|
||||
import { CLEAR_TIME_BOUNDARY_THRESHOLD } from './constants';
|
||||
import {
|
||||
filterBySearchCriteria,
|
||||
getCurrentDuration,
|
||||
getPendingReorder,
|
||||
getSortedSpeakerStatsIds,
|
||||
getTimelineBoundaries,
|
||||
resetHiddenStats
|
||||
} from './functions';
|
||||
|
||||
MiddlewareRegistry.register(({ dispatch, getState }: IStore) => (next: Function) => (action: AnyAction) => {
|
||||
switch (action.type) {
|
||||
case INIT_SEARCH: {
|
||||
const state = getState();
|
||||
const stats = filterBySearchCriteria(state);
|
||||
|
||||
dispatch(updateStats(stats));
|
||||
break;
|
||||
}
|
||||
|
||||
case INIT_UPDATE_STATS:
|
||||
if (action.getSpeakerStats) {
|
||||
const state = getState();
|
||||
const speakerStats = { ...action.getSpeakerStats() };
|
||||
const stats = filterBySearchCriteria(state, speakerStats);
|
||||
const pendingReorder = getPendingReorder(state);
|
||||
|
||||
batch(() => {
|
||||
if (pendingReorder) {
|
||||
dispatch(updateSortedSpeakerStatsIds(getSortedSpeakerStatsIds(state, stats) ?? []));
|
||||
}
|
||||
|
||||
dispatch(updateStats(stats));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case RESET_SEARCH_CRITERIA: {
|
||||
const state = getState();
|
||||
const stats = resetHiddenStats(state);
|
||||
|
||||
dispatch(updateStats(stats));
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_JOINED:
|
||||
case PARTICIPANT_LEFT:
|
||||
case PARTICIPANT_KICKED:
|
||||
case PARTICIPANT_UPDATED: {
|
||||
const { pendingReorder } = getState()['features/speaker-stats'];
|
||||
|
||||
if (!pendingReorder) {
|
||||
dispatch(initReorderStats());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ADD_TO_OFFSET: {
|
||||
const state = getState();
|
||||
const { timelineBoundary } = state['features/speaker-stats'];
|
||||
const { right } = getTimelineBoundaries(state);
|
||||
const currentDuration = getCurrentDuration(state) ?? 0;
|
||||
|
||||
if (Math.abs((right + action.value) - currentDuration) < CLEAR_TIME_BOUNDARY_THRESHOLD) {
|
||||
dispatch(clearTimelineBoundary());
|
||||
} else if (!timelineBoundary) {
|
||||
dispatch(setTimelineBoundary(currentDuration ?? 0));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
188
react/features/speaker-stats/reducer.ts
Normal file
188
react/features/speaker-stats/reducer.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { assign } from 'lodash-es';
|
||||
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
import { FaceLandmarks } from '../face-landmarks/types';
|
||||
|
||||
import {
|
||||
ADD_TO_OFFSET,
|
||||
ADD_TO_OFFSET_LEFT,
|
||||
ADD_TO_OFFSET_RIGHT,
|
||||
INIT_REORDER_STATS,
|
||||
INIT_SEARCH,
|
||||
RESET_SEARCH_CRITERIA,
|
||||
SET_PANNING,
|
||||
SET_TIMELINE_BOUNDARY,
|
||||
TOGGLE_FACE_EXPRESSIONS,
|
||||
UPDATE_SORTED_SPEAKER_STATS_IDS,
|
||||
UPDATE_STATS
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* The initial state of the feature speaker-stats.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
const INITIAL_STATE = {
|
||||
stats: {},
|
||||
isOpen: false,
|
||||
pendingReorder: true,
|
||||
criteria: null,
|
||||
showFaceExpressions: false,
|
||||
sortedSpeakerStatsIds: [],
|
||||
timelineBoundary: null,
|
||||
offsetLeft: 0,
|
||||
offsetRight: 0,
|
||||
timelinePanning: {
|
||||
active: false,
|
||||
x: 0
|
||||
}
|
||||
};
|
||||
|
||||
export interface ISpeaker {
|
||||
addFaceLandmarks: (faceLandmarks: FaceLandmarks) => void;
|
||||
displayName?: string;
|
||||
getDisplayName: () => string;
|
||||
getFaceLandmarks: () => FaceLandmarks[];
|
||||
getTotalDominantSpeakerTime: () => number;
|
||||
getUserId: () => string;
|
||||
hasLeft: () => boolean;
|
||||
hidden?: boolean;
|
||||
isDominantSpeaker: () => boolean;
|
||||
isLocalStats: () => boolean;
|
||||
isModerator?: boolean;
|
||||
markAsHasLeft: () => boolean;
|
||||
setDisplayName: (newName: string) => void;
|
||||
setDominantSpeaker: (isNowDominantSpeaker: boolean, silence: boolean) => void;
|
||||
setFaceLandmarks: (faceLandmarks: FaceLandmarks[]) => void;
|
||||
}
|
||||
|
||||
export interface ISpeakerStats {
|
||||
[key: string]: ISpeaker;
|
||||
}
|
||||
|
||||
export interface ISpeakerStatsState {
|
||||
criteria: string | null;
|
||||
isOpen: boolean;
|
||||
offsetLeft: number;
|
||||
offsetRight: number;
|
||||
pendingReorder: boolean;
|
||||
showFaceExpressions: boolean;
|
||||
sortedSpeakerStatsIds: Array<string>;
|
||||
stats: ISpeakerStats;
|
||||
timelineBoundary: number | null;
|
||||
timelinePanning: {
|
||||
active: boolean;
|
||||
x: number;
|
||||
};
|
||||
}
|
||||
|
||||
ReducerRegistry.register<ISpeakerStatsState>('features/speaker-stats',
|
||||
(state = INITIAL_STATE, action): ISpeakerStatsState => {
|
||||
switch (action.type) {
|
||||
case INIT_SEARCH:
|
||||
return _updateCriteria(state, action);
|
||||
case UPDATE_STATS:
|
||||
return _updateStats(state, action);
|
||||
case INIT_REORDER_STATS:
|
||||
return _initReorderStats(state);
|
||||
case UPDATE_SORTED_SPEAKER_STATS_IDS:
|
||||
return _updateSortedSpeakerStats(state, action);
|
||||
case RESET_SEARCH_CRITERIA:
|
||||
return _updateCriteria(state, { criteria: null });
|
||||
case TOGGLE_FACE_EXPRESSIONS: {
|
||||
return {
|
||||
...state,
|
||||
showFaceExpressions: !state.showFaceExpressions
|
||||
};
|
||||
}
|
||||
case ADD_TO_OFFSET: {
|
||||
return {
|
||||
...state,
|
||||
offsetLeft: state.offsetLeft + action.value,
|
||||
offsetRight: state.offsetRight + action.value
|
||||
};
|
||||
}
|
||||
case ADD_TO_OFFSET_RIGHT: {
|
||||
return {
|
||||
...state,
|
||||
offsetRight: state.offsetRight + action.value
|
||||
};
|
||||
}
|
||||
case ADD_TO_OFFSET_LEFT: {
|
||||
return {
|
||||
...state,
|
||||
offsetLeft: state.offsetLeft + action.value
|
||||
};
|
||||
}
|
||||
case SET_TIMELINE_BOUNDARY: {
|
||||
return {
|
||||
...state,
|
||||
timelineBoundary: action.boundary
|
||||
};
|
||||
}
|
||||
case SET_PANNING: {
|
||||
return {
|
||||
...state,
|
||||
timelinePanning: action.panning
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
/**
|
||||
* Reduces a specific Redux action INIT_SEARCH of the feature
|
||||
* speaker-stats.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature speaker-stats.
|
||||
* @param {Action} action - The Redux action INIT_SEARCH to reduce.
|
||||
* @private
|
||||
* @returns {Object} The new state after the reduction of the specified action.
|
||||
*/
|
||||
function _updateCriteria(state: ISpeakerStatsState, { criteria }: { criteria: string | null; }) {
|
||||
return assign({}, state, { criteria });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a specific Redux action UPDATE_STATS of the feature speaker-stats.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature speaker-stats.
|
||||
* @param {Action} action - The Redux action UPDATE_STATS to reduce.
|
||||
* @private
|
||||
* @returns {Object} - The new state after the reduction of the specified action.
|
||||
*/
|
||||
function _updateStats(state: ISpeakerStatsState, { stats }: { stats: any; }) {
|
||||
return {
|
||||
...state,
|
||||
stats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a specific Redux action UPDATE_SORTED_SPEAKER_STATS_IDS of the feature speaker-stats.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature speaker-stats.
|
||||
* @param {Action} action - The Redux action UPDATE_SORTED_SPEAKER_STATS_IDS to reduce.
|
||||
* @private
|
||||
* @returns {Object} The new state after the reduction of the specified action.
|
||||
*/
|
||||
function _updateSortedSpeakerStats(state: ISpeakerStatsState, { participantIds }: { participantIds: Array<string>; }) {
|
||||
return {
|
||||
...state,
|
||||
sortedSpeakerStatsIds: participantIds,
|
||||
pendingReorder: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a specific Redux action INIT_REORDER_STATS of the feature
|
||||
* speaker-stats.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature speaker-stats.
|
||||
* @private
|
||||
* @returns {Object} The new state after the reduction of the specified action.
|
||||
*/
|
||||
function _initReorderStats(state: ISpeakerStatsState) {
|
||||
return assign({}, state, { pendingReorder: true });
|
||||
}
|
||||
Reference in New Issue
Block a user