Highlight speaker on group calls
This commit is contained in:
parent
3d4248e070
commit
23cbd2c8b3
6 changed files with 80 additions and 25 deletions
|
@ -3972,6 +3972,21 @@ button.module-image__border-overlay:focus {
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
transition: transform 200ms linear, width 200ms linear, height 200ms linear;
|
transition: transform 200ms linear, width 200ms linear, height 200ms linear;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0 solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: border-width 200ms, border-color 200ms;
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
&--speaking:after {
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
&__remote-video {
|
&__remote-video {
|
||||||
// The background-color is seen while the video loads.
|
// The background-color is seen while the video loads.
|
||||||
background-color: $color-gray-75;
|
background-color: $color-gray-75;
|
||||||
|
|
|
@ -40,11 +40,15 @@ import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPerm
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||||
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
|
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
|
||||||
import { CallingAudioIndicator } from './CallingAudioIndicator';
|
import {
|
||||||
|
CallingAudioIndicator,
|
||||||
|
SPEAKING_LINGER_MS,
|
||||||
|
} from './CallingAudioIndicator';
|
||||||
import {
|
import {
|
||||||
useActiveCallShortcuts,
|
useActiveCallShortcuts,
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
} from '../hooks/useKeyboardShortcuts';
|
} from '../hooks/useKeyboardShortcuts';
|
||||||
|
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
activeCall: ActiveCallType;
|
activeCall: ActiveCallType;
|
||||||
|
@ -155,6 +159,11 @@ export function CallScreen({
|
||||||
showParticipantsList,
|
showParticipantsList,
|
||||||
} = activeCall;
|
} = activeCall;
|
||||||
|
|
||||||
|
const isSpeaking = useValueAtFixedRate(
|
||||||
|
localAudioLevel > 0,
|
||||||
|
SPEAKING_LINGER_MS
|
||||||
|
);
|
||||||
|
|
||||||
useActivateSpeakerViewOnPresenting({
|
useActivateSpeakerViewOnPresenting({
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
switchToPresentationView,
|
switchToPresentationView,
|
||||||
|
@ -536,6 +545,7 @@ export function CallScreen({
|
||||||
<CallingAudioIndicator
|
<CallingAudioIndicator
|
||||||
hasAudio={hasLocalAudio}
|
hasAudio={hasLocalAudio}
|
||||||
audioLevel={localAudioLevel}
|
audioLevel={localAudioLevel}
|
||||||
|
shouldShowSpeaking={isSpeaking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,8 +4,12 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { boolean } from '@storybook/addon-knobs';
|
import { boolean } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
import { CallingAudioIndicator } from './CallingAudioIndicator';
|
import {
|
||||||
|
CallingAudioIndicator,
|
||||||
|
SPEAKING_LINGER_MS,
|
||||||
|
} from './CallingAudioIndicator';
|
||||||
import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants';
|
import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants';
|
||||||
|
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/CallingAudioIndicator',
|
title: 'Components/CallingAudioIndicator',
|
||||||
|
@ -24,10 +28,13 @@ export function Extreme(): JSX.Element {
|
||||||
};
|
};
|
||||||
}, [audioLevel, setAudioLevel]);
|
}, [audioLevel, setAudioLevel]);
|
||||||
|
|
||||||
|
const isSpeaking = useValueAtFixedRate(audioLevel > 0, SPEAKING_LINGER_MS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallingAudioIndicator
|
<CallingAudioIndicator
|
||||||
hasAudio={boolean('hasAudio', true)}
|
hasAudio={boolean('hasAudio', true)}
|
||||||
audioLevel={audioLevel}
|
audioLevel={audioLevel}
|
||||||
|
shouldShowSpeaking={isSpeaking}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -45,10 +52,13 @@ export function Random(): JSX.Element {
|
||||||
};
|
};
|
||||||
}, [audioLevel, setAudioLevel]);
|
}, [audioLevel, setAudioLevel]);
|
||||||
|
|
||||||
|
const isSpeaking = useValueAtFixedRate(audioLevel > 0, SPEAKING_LINGER_MS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallingAudioIndicator
|
<CallingAudioIndicator
|
||||||
hasAudio={boolean('hasAudio', true)}
|
hasAudio={boolean('hasAudio', true)}
|
||||||
audioLevel={audioLevel}
|
audioLevel={audioLevel}
|
||||||
|
shouldShowSpeaking={isSpeaking}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,14 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useSpring, animated } from '@react-spring/web';
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
|
||||||
import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants';
|
import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
|
||||||
const SPEAKING_LINGER_MS = 500;
|
export const SPEAKING_LINGER_MS = 500;
|
||||||
|
|
||||||
const BASE_CLASS_NAME = 'CallingAudioIndicator';
|
const BASE_CLASS_NAME = 'CallingAudioIndicator';
|
||||||
const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`;
|
const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`;
|
||||||
|
|
||||||
|
@ -104,23 +102,12 @@ function Bars({ audioLevel }: { audioLevel: number }): ReactElement {
|
||||||
export function CallingAudioIndicator({
|
export function CallingAudioIndicator({
|
||||||
hasAudio,
|
hasAudio,
|
||||||
audioLevel,
|
audioLevel,
|
||||||
}: Readonly<{ hasAudio: boolean; audioLevel: number }>): ReactElement {
|
shouldShowSpeaking,
|
||||||
const [shouldShowSpeaking, setShouldShowSpeaking] = useState(audioLevel > 0);
|
}: Readonly<{
|
||||||
|
hasAudio: boolean;
|
||||||
useEffect(() => {
|
audioLevel: number;
|
||||||
if (audioLevel > 0) {
|
shouldShowSpeaking: boolean;
|
||||||
setShouldShowSpeaking(true);
|
}>): ReactElement {
|
||||||
} else if (shouldShowSpeaking) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setShouldShowSpeaking(false);
|
|
||||||
}, SPEAKING_LINGER_MS);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return noop;
|
|
||||||
}, [audioLevel, shouldShowSpeaking]);
|
|
||||||
|
|
||||||
if (!hasAudio) {
|
if (!hasAudio) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -16,13 +16,17 @@ import type { GroupCallRemoteParticipantType } from '../types/Calling';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import { AvatarColors } from '../types/Colors';
|
import { AvatarColors } from '../types/Colors';
|
||||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||||
import { CallingAudioIndicator } from './CallingAudioIndicator';
|
import {
|
||||||
|
CallingAudioIndicator,
|
||||||
|
SPEAKING_LINGER_MS,
|
||||||
|
} from './CallingAudioIndicator';
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import { ContactName } from './conversation/ContactName';
|
import { ContactName } from './conversation/ContactName';
|
||||||
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
|
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
|
||||||
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
|
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
|
||||||
|
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
|
||||||
|
|
||||||
const MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES = 5000;
|
const MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES = 5000;
|
||||||
const MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES = 60000;
|
const MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES = 60000;
|
||||||
|
@ -79,6 +83,11 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
||||||
videoAspectRatio,
|
videoAspectRatio,
|
||||||
} = props.remoteParticipant;
|
} = props.remoteParticipant;
|
||||||
|
|
||||||
|
const isSpeaking = useValueAtFixedRate(
|
||||||
|
!props.isInPip ? props.audioLevel > 0 : false,
|
||||||
|
SPEAKING_LINGER_MS
|
||||||
|
);
|
||||||
|
|
||||||
const [hasReceivedVideoRecently, setHasReceivedVideoRecently] =
|
const [hasReceivedVideoRecently, setHasReceivedVideoRecently] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isWide, setIsWide] = useState<boolean>(
|
const [isWide, setIsWide] = useState<boolean>(
|
||||||
|
@ -266,7 +275,11 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="module-ongoing-call__group-call-remote-participant"
|
className={classNames(
|
||||||
|
'module-ongoing-call__group-call-remote-participant',
|
||||||
|
isSpeaking &&
|
||||||
|
'module-ongoing-call__group-call-remote-participant--speaking'
|
||||||
|
)}
|
||||||
ref={intersectionRef}
|
ref={intersectionRef}
|
||||||
style={containerStyles}
|
style={containerStyles}
|
||||||
>
|
>
|
||||||
|
@ -283,6 +296,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
||||||
<CallingAudioIndicator
|
<CallingAudioIndicator
|
||||||
hasAudio={hasRemoteAudio}
|
hasAudio={hasRemoteAudio}
|
||||||
audioLevel={props.audioLevel}
|
audioLevel={props.audioLevel}
|
||||||
|
shouldShowSpeaking={isSpeaking}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
19
ts/hooks/useValueAtFixedRate.ts
Normal file
19
ts/hooks/useValueAtFixedRate.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useValueAtFixedRate<T>(value: T, rate: number): T {
|
||||||
|
const [currentValue, setCurrentValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setCurrentValue(value);
|
||||||
|
}, rate);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [value, rate]);
|
||||||
|
|
||||||
|
return currentValue;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue