Show speaking indicator in 1:1 calls

This commit is contained in:
Scott Nonnenberg
2025-03-01 05:42:08 +10:00
committed by GitHub
parent 938f39cad1
commit cf4b068ab2
12 changed files with 134 additions and 13 deletions

View File

@@ -4136,6 +4136,12 @@ button.module-image__border-overlay:focus {
flex: 1; flex: 1;
} }
&__direct-call-speaking-indicator {
position: absolute;
inset-inline-end: 16px;
bottom: 112px;
}
&__participants { &__participants {
display: flex; display: flex;
flex: 1 1 0; flex: 1 1 0;

View File

@@ -204,6 +204,7 @@ export function OngoingDirectCall(): JSX.Element {
callMode: CallMode.Direct, callMode: CallMode.Direct,
callState: CallState.Accepted, callState: CallState.Accepted,
peekedParticipants: [], peekedParticipants: [],
remoteAudioLevel: 0,
remoteParticipants: [ remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Remy' }, { hasRemoteVideo: true, presenting: false, title: 'Remy' },
], ],
@@ -291,6 +292,7 @@ export function CallRequestNeeded(): JSX.Element {
callMode: CallMode.Direct, callMode: CallMode.Direct,
callState: CallState.Accepted, callState: CallState.Accepted,
peekedParticipants: [], peekedParticipants: [],
remoteAudioLevel: 0,
remoteParticipants: [ remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Mike' }, { hasRemoteVideo: true, presenting: false, title: 'Mike' },
], ],

View File

@@ -84,6 +84,7 @@ const createActiveDirectCallProp = (
conversation, conversation,
callState: overrideProps.callState ?? CallState.Accepted, callState: overrideProps.callState ?? CallState.Accepted,
peekedParticipants: [] as [], peekedParticipants: [] as [],
remoteAudioLevel: 0,
remoteParticipants: [ remoteParticipants: [
{ {
hasRemoteVideo: overrideProps.hasRemoteVideo ?? false, hasRemoteVideo: overrideProps.hasRemoteVideo ?? false,

View File

@@ -876,6 +876,15 @@ export function CallScreen({
} }
/> />
) : null} ) : null}
{activeCall.callMode === CallMode.Direct && (
<div className="module-ongoing-call__direct-call-speaking-indicator">
<CallingAudioIndicator
hasAudio
audioLevel={activeCall.remoteAudioLevel}
shouldShowSpeaking={activeCall.remoteAudioLevel > 0}
/>
</div>
)}
{/* We render the local preview first and set the footer flex direction to row-reverse {/* We render the local preview first and set the footer flex direction to row-reverse
to ensure the preview is visible at low viewport widths. */} to ensure the preview is visible at low viewport widths. */}
<div className="module-ongoing-call__footer"> <div className="module-ongoing-call__footer">

View File

@@ -61,6 +61,7 @@ const getDefaultCall = (overrides: Overrides): ActiveDirectCallType => {
callMode: CallMode.Direct as CallMode.Direct, callMode: CallMode.Direct as CallMode.Direct,
callState: CallState.Accepted, callState: CallState.Accepted,
peekedParticipants: [], peekedParticipants: [],
remoteAudioLevel: 0,
remoteParticipants: [ remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Arsene' }, { hasRemoteVideo: true, presenting: false, title: 'Arsene' },
], ],

View File

@@ -195,6 +195,7 @@ type CallingReduxInterface = Pick<
| 'callStateChange' | 'callStateChange'
| 'cancelIncomingGroupCallRing' | 'cancelIncomingGroupCallRing'
| 'cancelPresenting' | 'cancelPresenting'
| 'directCallAudioLevelsChange'
| 'groupCallAudioLevelsChange' | 'groupCallAudioLevelsChange'
| 'groupCallEnded' | 'groupCallEnded'
| 'groupCallRaisedHandsChange' | 'groupCallRaisedHandsChange'
@@ -3038,7 +3039,8 @@ export class CallingClass {
} }
#attachToCall(conversation: ConversationModel, call: Call): void { #attachToCall(conversation: ConversationModel, call: Call): void {
this.#callsLookup[conversation.id] = call; const conversationId = conversation.id;
this.#callsLookup[conversationId] = call;
const reduxInterface = this.#reduxInterface; const reduxInterface = this.#reduxInterface;
if (!reduxInterface) { if (!reduxInterface) {
@@ -3056,7 +3058,7 @@ export class CallingClass {
if (call.state === CallState.Ended) { if (call.state === CallState.Ended) {
this.#stopDeviceReselectionTimer(); this.#stopDeviceReselectionTimer();
this.#lastMediaDeviceSettings = undefined; this.#lastMediaDeviceSettings = undefined;
delete this.#callsLookup[conversation.id]; delete this.#callsLookup[conversationId];
} }
const localCallEvent = getLocalCallEventFromDirectCall(call); const localCallEvent = getLocalCallEventFromDirectCall(call);
@@ -3072,7 +3074,7 @@ export class CallingClass {
} }
reduxInterface.callStateChange({ reduxInterface.callStateChange({
conversationId: conversation.id, conversationId,
callState: call.state, callState: call.state,
callEndedReason: call.endedReason, callEndedReason: call.endedReason,
acceptedTime, acceptedTime,
@@ -3087,7 +3089,7 @@ export class CallingClass {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
call.handleRemoteVideoEnabled = () => { call.handleRemoteVideoEnabled = () => {
reduxInterface.remoteVideoChange({ reduxInterface.remoteVideoChange({
conversationId: conversation.id, conversationId,
hasVideo: call.remoteVideoEnabled, hasVideo: call.remoteVideoEnabled,
}); });
}; };
@@ -3095,11 +3097,20 @@ export class CallingClass {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
call.handleRemoteSharingScreen = () => { call.handleRemoteSharingScreen = () => {
reduxInterface.remoteSharingScreenChange({ reduxInterface.remoteSharingScreenChange({
conversationId: conversation.id, conversationId,
isSharingScreen: Boolean(call.remoteSharingScreen), isSharingScreen: Boolean(call.remoteSharingScreen),
}); });
}; };
// eslint-disable-next-line no-param-reassign
call.handleAudioLevels = () => {
reduxInterface.directCallAudioLevelsChange({
conversationId,
localAudioLevel: call.outgoingAudioLevel,
remoteAudioLevel: call.remoteAudioLevel,
});
};
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
call.handleLowBandwidthForVideo = _recovered => { call.handleLowBandwidthForVideo = _recovered => {
// TODO: Implement handling of "low outgoing bandwidth for video" notification. // TODO: Implement handling of "low outgoing bandwidth for video" notification.
@@ -3284,8 +3295,7 @@ export class CallingClass {
iceServers, iceServers,
hideIp: shouldRelayCalls || isContactUntrusted, hideIp: shouldRelayCalls || isContactUntrusted,
dataMode: DataMode.Normal, dataMode: DataMode.Normal,
// TODO: DESKTOP-3101 audioLevelsIntervalMillis: AUDIO_LEVEL_INTERVAL_MS,
// audioLevelsIntervalMillis: AUDIO_LEVEL_INTERVAL_MS,
}; };
log.info('CallingClass.handleStartCall(): Proceeding'); log.info('CallingClass.handleStartCall(): Proceeding');

View File

@@ -47,6 +47,9 @@ const logger = createLogger({
if (action.type === 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE') { if (action.type === 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE') {
return false; return false;
} }
if (action.type === 'calling/DIRECT_CALL_AUDIO_LEVELS_CHANGE') {
return false;
}
return true; return true;
}, },
}); });

View File

@@ -148,6 +148,7 @@ export type DirectCallStateType = {
isSharingScreen?: boolean; isSharingScreen?: boolean;
isVideoCall: boolean; isVideoCall: boolean;
hasRemoteVideo?: boolean; hasRemoteVideo?: boolean;
remoteAudioLevel: number;
}; };
type GroupCallRingStateType = ReadonlyDeep< type GroupCallRingStateType = ReadonlyDeep<
@@ -624,6 +625,8 @@ const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL'; const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE'; const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
const DIRECT_CALL_AUDIO_LEVELS_CHANGE =
'calling/DIRECT_CALL_AUDIO_LEVELS_CHANGE';
const GROUP_CALL_ENDED = 'calling/GROUP_CALL_ENDED'; const GROUP_CALL_ENDED = 'calling/GROUP_CALL_ENDED';
const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE'; const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
@@ -734,11 +737,19 @@ type GroupCallAudioLevelsChangeActionPayloadType = ReadonlyDeep<{
localAudioLevel: number; localAudioLevel: number;
remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>; remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>;
}>; }>;
type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{ type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE'; type: typeof GROUP_CALL_AUDIO_LEVELS_CHANGE;
payload: GroupCallAudioLevelsChangeActionPayloadType; payload: GroupCallAudioLevelsChangeActionPayloadType;
}>; }>;
type DirectCallAudioLevelsChangeActionPayloadType = ReadonlyDeep<{
conversationId: string;
localAudioLevel: number;
remoteAudioLevel: number;
}>;
type DirectCallAudioLevelsChangeActionType = ReadonlyDeep<{
type: typeof DIRECT_CALL_AUDIO_LEVELS_CHANGE;
payload: DirectCallAudioLevelsChangeActionPayloadType;
}>;
type GroupCallEndedActionPayloadType = ReadonlyDeep<{ type GroupCallEndedActionPayloadType = ReadonlyDeep<{
conversationId: string; conversationId: string;
@@ -959,6 +970,7 @@ export type CallingActionType =
| CancelIncomingGroupCallRingActionType | CancelIncomingGroupCallRingActionType
| ChangeCallViewActionType | ChangeCallViewActionType
| DenyUserActionType | DenyUserActionType
| DirectCallAudioLevelsChangeActionType
| StartCallingLobbyActionType | StartCallingLobbyActionType
| StartCallLinkLobbyActionType | StartCallLinkLobbyActionType
| CallStateChangeFulfilledActionType | CallStateChangeFulfilledActionType
@@ -1205,6 +1217,12 @@ function callStateChange(
}; };
} }
function directCallAudioLevelsChange(
payload: DirectCallAudioLevelsChangeActionPayloadType
): DirectCallAudioLevelsChangeActionType {
return { type: DIRECT_CALL_AUDIO_LEVELS_CHANGE, payload };
}
function changeIODevice( function changeIODevice(
payload: ChangeIODevicePayloadType payload: ChangeIODevicePayloadType
): ThunkAction< ): ThunkAction<
@@ -2693,6 +2711,7 @@ export const actions = {
declineCall, declineCall,
deleteCallLink, deleteCallLink,
denyUser, denyUser,
directCallAudioLevelsChange,
getPresentingSources, getPresentingSources,
groupCallAudioLevelsChange, groupCallAudioLevelsChange,
groupCallEnded, groupCallEnded,
@@ -2941,6 +2960,7 @@ export function reducer(
conversationId, conversationId,
isIncoming: false, isIncoming: false,
isVideoCall: action.payload.hasLocalVideo, isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
}; };
outgoingRing = true; outgoingRing = true;
newAdhocCalls = adhocCalls; newAdhocCalls = adhocCalls;
@@ -3066,6 +3086,7 @@ export function reducer(
callState: CallState.Prering, callState: CallState.Prering,
isIncoming: false, isIncoming: false,
isVideoCall: action.payload.hasLocalVideo, isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
}, },
}, },
activeCallState: { activeCallState: {
@@ -3225,6 +3246,7 @@ export function reducer(
callState: CallState.Prering, callState: CallState.Prering,
isIncoming: true, isIncoming: true,
isVideoCall: action.payload.isVideoCall, isVideoCall: action.payload.isVideoCall,
remoteAudioLevel: 0,
}, },
}, },
}; };
@@ -3307,6 +3329,7 @@ export function reducer(
callState: CallState.Prering, callState: CallState.Prering,
isIncoming: false, isIncoming: false,
isVideoCall: action.payload.hasLocalVideo, isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
}, },
}, },
activeCallState: { activeCallState: {
@@ -3392,17 +3415,29 @@ export function reducer(
if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) { if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) {
const { callMode, conversationId, remoteDeviceStates } = action.payload; const { callMode, conversationId, remoteDeviceStates } = action.payload;
const { activeCallState } = state; const { activeCallState } = state;
const existingCall = getGroupCall(conversationId, state, callMode);
if (
activeCallState &&
(activeCallState.state === 'Waiting' ||
activeCallState.conversationId !== conversationId)
) {
log.warn(
`${action.type}: Cannot update levels; activeCall doesn't match conversation`,
activeCallState
);
return state;
}
// The PiP check is an optimization. We don't need to update audio levels if the user // The PiP check is an optimization. We don't need to update audio levels if the user
// cannot see them. // cannot see them.
const existingCall = getGroupCall(conversationId, state, callMode);
if ( if (
!activeCallState || !activeCallState ||
activeCallState.state === 'Waiting' ||
activeCallState.pip || activeCallState.pip ||
!existingCall !existingCall ||
(existingCall.callMode !== CallMode.Adhoc &&
existingCall.callMode !== CallMode.Group)
) { ) {
return state; return state;
} }
@@ -3445,6 +3480,49 @@ export function reducer(
}; };
} }
if (action.type === DIRECT_CALL_AUDIO_LEVELS_CHANGE) {
const { conversationId } = action.payload;
const { activeCallState } = state;
const existingCall = getOwn(state.callsByConversation, conversationId);
if (
activeCallState &&
(activeCallState.state === 'Waiting' ||
activeCallState.conversationId !== conversationId)
) {
log.warn(
`${action.type}: Cannot update levels; activeCall doesn't match conversation`,
activeCallState
);
return state;
}
// The PiP check is an optimization. We don't need to update audio levels if the user
// cannot see them.
if (
!activeCallState ||
activeCallState.pip ||
!existingCall ||
existingCall.callMode !== CallMode.Direct
) {
return state;
}
const localAudioLevel = truncateAudioLevel(action.payload.localAudioLevel);
const remoteAudioLevel = truncateAudioLevel(
action.payload.remoteAudioLevel
);
return {
...state,
activeCallState: { ...activeCallState, localAudioLevel },
callsByConversation: {
...state.callsByConversation,
[conversationId]: { ...existingCall, remoteAudioLevel },
},
};
}
if (action.type === GROUP_CALL_STATE_CHANGE) { if (action.type === GROUP_CALL_STATE_CHANGE) {
const { const {
callMode, callMode,

View File

@@ -204,6 +204,7 @@ const mapStateToActiveCallProp = (
callMode: CallMode.Direct, callMode: CallMode.Direct,
callState: call.callState, callState: call.callState,
peekedParticipants: [], peekedParticipants: [],
remoteAudioLevel: call.remoteAudioLevel,
remoteParticipants: [ remoteParticipants: [
{ {
hasRemoteVideo: Boolean(call.hasRemoteVideo), hasRemoteVideo: Boolean(call.hasRemoteVideo),

View File

@@ -66,6 +66,7 @@ describe('calling duck', () => {
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
}; };
const stateWithDirectCall: CallingStateType = { const stateWithDirectCall: CallingStateType = {
...getEmptyState(), ...getEmptyState(),
@@ -102,6 +103,7 @@ describe('calling duck', () => {
isIncoming: true, isIncoming: true,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
} satisfies DirectCallStateType, } satisfies DirectCallStateType,
}, },
}; };
@@ -2296,6 +2298,7 @@ describe('calling duck', () => {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
isIncoming: false, isIncoming: false,
isVideoCall: true, isVideoCall: true,
remoteAudioLevel: 0,
}); });
assert.deepEqual(result.activeCallState, { assert.deepEqual(result.activeCallState, {
state: 'Active', state: 'Active',
@@ -2694,6 +2697,7 @@ describe('calling duck', () => {
callState: CallState.Prering, callState: CallState.Prering,
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
remoteAudioLevel: 0,
}); });
assert.deepEqual(result.activeCallState, { assert.deepEqual(result.activeCallState, {
state: 'Active', state: 'Active',
@@ -2934,6 +2938,7 @@ describe('calling duck', () => {
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
}); });
}); });
}); });

View File

@@ -60,6 +60,7 @@ describe('state/selectors/calling', () => {
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
}, },
}, },
}; };
@@ -89,6 +90,7 @@ describe('state/selectors/calling', () => {
isIncoming: true, isIncoming: true,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
}; };
const stateWithIncomingDirectCall: CallingStateType = { const stateWithIncomingDirectCall: CallingStateType = {
@@ -151,6 +153,7 @@ describe('state/selectors/calling', () => {
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
}, },
} }
); );
@@ -176,6 +179,7 @@ describe('state/selectors/calling', () => {
isIncoming: false, isIncoming: false,
isVideoCall: false, isVideoCall: false,
hasRemoteVideo: false, hasRemoteVideo: false,
remoteAudioLevel: 0,
} }
); );
}); });

View File

@@ -78,6 +78,7 @@ export type ActiveDirectCallType = ActiveCallBaseType & {
serviceId?: ServiceIdString; serviceId?: ServiceIdString;
}, },
]; ];
remoteAudioLevel: number;
}; };
export type ActiveGroupCallType = ActiveCallBaseType & { export type ActiveGroupCallType = ActiveCallBaseType & {