init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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';

View 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
};
}

View File

@@ -0,0 +1 @@
export * from './actions.any';

View File

@@ -0,0 +1 @@
export * from './actions.any';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View 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
}
};

View 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 });
}

View File

@@ -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>
);
}

View 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;

View File

@@ -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));

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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;
}
}

View 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);
});

View 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 });
}