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;
}
&__direct-call-speaking-indicator {
position: absolute;
inset-inline-end: 16px;
bottom: 112px;
}
&__participants {
display: flex;
flex: 1 1 0;

View File

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

View File

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

View File

@@ -876,6 +876,15 @@ export function CallScreen({
}
/>
) : 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
to ensure the preview is visible at low viewport widths. */}
<div className="module-ongoing-call__footer">

View File

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

View File

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

View File

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

View File

@@ -148,6 +148,7 @@ export type DirectCallStateType = {
isSharingScreen?: boolean;
isVideoCall: boolean;
hasRemoteVideo?: boolean;
remoteAudioLevel: number;
};
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 DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
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_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
@@ -734,11 +737,19 @@ type GroupCallAudioLevelsChangeActionPayloadType = ReadonlyDeep<{
localAudioLevel: number;
remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>;
}>;
type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
type: typeof GROUP_CALL_AUDIO_LEVELS_CHANGE;
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<{
conversationId: string;
@@ -959,6 +970,7 @@ export type CallingActionType =
| CancelIncomingGroupCallRingActionType
| ChangeCallViewActionType
| DenyUserActionType
| DirectCallAudioLevelsChangeActionType
| StartCallingLobbyActionType
| StartCallLinkLobbyActionType
| CallStateChangeFulfilledActionType
@@ -1205,6 +1217,12 @@ function callStateChange(
};
}
function directCallAudioLevelsChange(
payload: DirectCallAudioLevelsChangeActionPayloadType
): DirectCallAudioLevelsChangeActionType {
return { type: DIRECT_CALL_AUDIO_LEVELS_CHANGE, payload };
}
function changeIODevice(
payload: ChangeIODevicePayloadType
): ThunkAction<
@@ -2693,6 +2711,7 @@ export const actions = {
declineCall,
deleteCallLink,
denyUser,
directCallAudioLevelsChange,
getPresentingSources,
groupCallAudioLevelsChange,
groupCallEnded,
@@ -2941,6 +2960,7 @@ export function reducer(
conversationId,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
};
outgoingRing = true;
newAdhocCalls = adhocCalls;
@@ -3066,6 +3086,7 @@ export function reducer(
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
},
},
activeCallState: {
@@ -3225,6 +3246,7 @@ export function reducer(
callState: CallState.Prering,
isIncoming: true,
isVideoCall: action.payload.isVideoCall,
remoteAudioLevel: 0,
},
},
};
@@ -3307,6 +3329,7 @@ export function reducer(
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
remoteAudioLevel: 0,
},
},
activeCallState: {
@@ -3392,17 +3415,29 @@ export function reducer(
if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) {
const { callMode, conversationId, remoteDeviceStates } = action.payload;
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
// cannot see them.
const existingCall = getGroupCall(conversationId, state, callMode);
if (
!activeCallState ||
activeCallState.state === 'Waiting' ||
activeCallState.pip ||
!existingCall
!existingCall ||
(existingCall.callMode !== CallMode.Adhoc &&
existingCall.callMode !== CallMode.Group)
) {
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) {
const {
callMode,

View File

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

View File

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

View File

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

View File

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