Highlight speaker on group calls

This commit is contained in:
Alvaro 2023-02-28 13:01:52 -07:00 committed by GitHub
parent 3d4248e070
commit 23cbd2c8b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 80 additions and 25 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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