Group calling: add speaker view

This commit is contained in:
Evan Hahn 2021-01-08 16:57:54 -06:00 committed by Scott Nonnenberg
parent fbfcdbf84e
commit b281420a40
16 changed files with 174 additions and 19 deletions

View file

@ -3147,6 +3147,14 @@
"message": "Fullscreen call",
"description": "Title for picture-in-picture toggle"
},
"calling__switch-view--to-grid": {
"message": "Switch to grid view",
"description": "Title for grid/speaker view toggle when on a call"
},
"calling__switch-view--to-speaker": {
"message": "Switch to speaker view",
"description": "Title for grid/speaker view toggle when on a call"
},
"calling__hangup": {
"message": "Leave call",
"description": "Title for hang up button"

View file

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m21.5 8.5h-4a1 1 0 0 1 -1-1v-2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1zm-6.5-1v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm-7.5 0v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm7.5 5.5v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm-7.5 0v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm7.5 5.5v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm-7.5 0v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm15-5.5v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1zm0 5.5v-2a1 1 0 0 0 -1-1h-4a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 788 B

View file

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m15 19.5h-12.5a1 1 0 0 1 -1-1v-13a1 1 0 0 1 1-1h12.5a1 1 0 0 1 1 1v13a1 1 0 0 1 -1 1zm7.5-12v-2a1 1 0 0 0 -1-1h-3a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1zm0 5.5v-2a1 1 0 0 0 -1-1h-3a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1zm0 5.5v-2a1 1 0 0 0 -1-1h-3a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 412 B

View file

@ -6090,11 +6090,13 @@ button.module-image__border-overlay:focus {
}
.module-calling-button {
$size: 22px;
&__participants {
@include color-svg('../images/icons/v2/group-solid-24.svg', $color-white);
display: inline-block;
height: 22px;
width: 22px;
height: $size;
width: $size;
&--container {
@include button-reset;
@ -6123,14 +6125,32 @@ button.module-image__border-overlay:focus {
'../images/icons/v2/settings-solid-16.svg',
$color-white
);
height: 22px;
width: 22px;
height: $size;
width: $size;
}
&__grid-view {
@include color-svg(
'../images/icons/v2/grid-view-solid-24.svg',
$color-white
);
height: $size;
width: $size;
}
&__speaker-view {
@include color-svg(
'../images/icons/v2/speaker-view-solid-24.svg',
$color-white
);
height: $size;
width: $size;
}
&__pip {
@include color-svg('../images/icons/v2/pip-minimize-24.svg', $color-white);
height: 22px;
width: 22px;
height: $size;
width: $size;
}
}

View file

@ -42,6 +42,7 @@ const getCommonActiveCallData = () => ({
joinedAt: Date.now(),
hasLocalAudio: boolean('hasLocalAudio', true),
hasLocalVideo: boolean('hasLocalVideo', false),
isInSpeakerView: boolean('isInSpeakerView', false),
pip: boolean('pip', false),
settingsDialogOpen: boolean('settingsDialogOpen', false),
showParticipantsList: boolean('showParticipantsList', false),
@ -87,6 +88,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
});
const story = storiesOf('Components/CallManager', module);

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
@ -73,6 +73,7 @@ export interface PropsType {
hangUp: (_: HangUpType) => void;
togglePip: () => void;
toggleSettings: () => void;
toggleSpeakerView: () => void;
}
interface ActiveCallManagerPropsType extends PropsType {
@ -100,6 +101,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleParticipants,
togglePip,
toggleSettings,
toggleSpeakerView,
}) => {
const {
conversation,
@ -265,6 +267,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
toggleSpeakerView={toggleSpeakerView}
/>
{settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && activeCall.callMode === CallMode.Group ? (

View file

@ -44,6 +44,7 @@ const conversation = {
interface OverridePropsBase {
hasLocalAudio?: boolean;
hasLocalVideo?: boolean;
isInSpeakerView?: boolean;
}
interface DirectCallOverrideProps extends OverridePropsBase {
@ -113,6 +114,10 @@ const createActiveCallProp = (
'hasLocalVideo',
overrideProps.hasLocalVideo || false
),
isInSpeakerView: boolean(
'isInSpeakerView',
overrideProps.isInSpeakerView || false
),
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
@ -152,6 +157,7 @@ const createProps = (
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
});
const story = storiesOf('Components/CallScreen', module);

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useRef, useEffect, useCallback } from 'react';
@ -53,6 +53,7 @@ export type PropsType = {
toggleParticipants: () => void;
togglePip: () => void;
toggleSettings: () => void;
toggleSpeakerView: () => void;
};
export const CallScreen: React.FC<PropsType> = ({
@ -71,6 +72,7 @@ export const CallScreen: React.FC<PropsType> = ({
toggleParticipants,
togglePip,
toggleSettings,
toggleSpeakerView,
}) => {
const {
conversation,
@ -190,6 +192,7 @@ export const CallScreen: React.FC<PropsType> = ({
<GroupCallRemoteParticipants
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isInSpeakerView={activeCall.isInSpeakerView}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
/>
@ -244,6 +247,7 @@ export const CallScreen: React.FC<PropsType> = ({
<CallingHeader
canPip
i18n={i18n}
isInSpeakerView={activeCall.isInSpeakerView}
isGroupCall={activeCall.callMode === CallMode.Group}
message={headerMessage}
participantCount={participantCount}
@ -252,6 +256,7 @@ export const CallScreen: React.FC<PropsType> = ({
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
toggleSpeakerView={toggleSpeakerView}
/>
</div>
{remoteParticipantsElement}

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -10,6 +10,7 @@ import { Theme } from '../util/theme';
export type PropsType = {
canPip?: boolean;
i18n: LocalizerType;
isInSpeakerView?: boolean;
isGroupCall?: boolean;
message?: string;
participantCount: number;
@ -18,11 +19,13 @@ export type PropsType = {
toggleParticipants?: () => void;
togglePip?: () => void;
toggleSettings: () => void;
toggleSpeakerView?: () => void;
};
export const CallingHeader = ({
canPip = false,
i18n,
isInSpeakerView,
isGroupCall = false,
message,
participantCount,
@ -31,6 +34,7 @@ export const CallingHeader = ({
toggleParticipants,
togglePip,
toggleSettings,
toggleSpeakerView,
}: PropsType): JSX.Element => (
<div className="module-calling__header">
{title ? (
@ -80,6 +84,33 @@ export const CallingHeader = ({
/>
</Tooltip>
</div>
{isGroupCall && participantCount > 2 && toggleSpeakerView && (
<div className="module-calling-tools__button">
<Tooltip
content={i18n(
isInSpeakerView
? 'calling__switch-view--to-grid'
: 'calling__switch-view--to-speaker'
)}
theme={Theme.Dark}
>
<button
aria-label={i18n(
isInSpeakerView
? 'calling__switch-view--to-grid'
: 'calling__switch-view--to-speaker'
)}
className={
isInSpeakerView
? 'module-calling-button__grid-view'
: 'module-calling-button__speaker-view'
}
onClick={toggleSpeakerView}
type="button"
/>
</Tooltip>
</div>
)}
{canPip && (
<div className="module-calling-tools__button">
<Tooltip content={i18n('calling__pip--on')} theme={Theme.Dark}>

View file

@ -39,6 +39,7 @@ const getCommonActiveCallData = () => ({
conversation,
hasLocalAudio: boolean('hasLocalAudio', true),
hasLocalVideo: boolean('hasLocalVideo', false),
isInSpeakerView: boolean('isInSpeakerView', false),
joinedAt: Date.now(),
pip: true,
settingsDialogOpen: false,

View file

@ -38,6 +38,7 @@ interface GridArrangement {
interface PropsType {
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
isInSpeakerView: boolean;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void;
}
@ -68,6 +69,7 @@ interface PropsType {
export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
getGroupCallVideoFrameSource,
i18n,
isInSpeakerView,
remoteParticipants,
setGroupCallVideoRequest,
}) => {
@ -122,6 +124,14 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
[remoteParticipants]
);
const gridParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
if (!sortedParticipants.length) {
return [];
}
const candidateParticipants = isInSpeakerView
? [sortedParticipants[0]]
: sortedParticipants;
// Imagine that we laid out all of the rows end-to-end. That's the maximum total
// width. So if there were 5 rows and the container was 100px wide, then we can't
// possibly fit more than 500px of participants.
@ -130,11 +140,16 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
// We do the same thing for participants, "laying them out end-to-end" until they
// exceed the maximum total width.
let totalWidth = 0;
return takeWhile(sortedParticipants, remoteParticipant => {
return takeWhile(candidateParticipants, remoteParticipant => {
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
return totalWidth < maxTotalWidth;
}).sort(stableParticipantComparator);
}, [maxRowCount, containerDimensions.width, sortedParticipants]);
}, [
containerDimensions.width,
isInSpeakerView,
maxRowCount,
sortedParticipants,
]);
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
() =>
sortedParticipants

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ThunkAction } from 'redux-thunk';
@ -68,12 +68,13 @@ export interface GroupCallStateType {
export interface ActiveCallStateType {
conversationId: string;
joinedAt?: number;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
isInSpeakerView: boolean;
joinedAt?: number;
pip: boolean;
settingsDialogOpen: boolean;
safetyNumberChangedUuids: Array<string>;
settingsDialogOpen: boolean;
showParticipantsList: boolean;
}
@ -243,6 +244,7 @@ const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
const TOGGLE_SPEAKER_VIEW = 'calling/TOGGLE_SPEAKER_VIEW';
type AcceptCallPendingActionType = {
type: 'calling/ACCEPT_CALL_PENDING';
@ -365,6 +367,10 @@ type ToggleSettingsActionType = {
type: 'calling/TOGGLE_SETTINGS';
};
type ToggleSpeakerViewActionType = {
type: 'calling/TOGGLE_SPEAKER_VIEW';
};
export type CallingActionType =
| AcceptCallPendingActionType
| CancelCallActionType
@ -389,7 +395,8 @@ export type CallingActionType =
| StartDirectCallActionType
| ToggleParticipantsActionType
| TogglePipActionType
| ToggleSettingsActionType;
| ToggleSettingsActionType
| ToggleSpeakerViewActionType;
// Action Creators
@ -856,6 +863,12 @@ function toggleSettings(): ToggleSettingsActionType {
};
}
function toggleSpeakerView(): ToggleSpeakerViewActionType {
return {
type: TOGGLE_SPEAKER_VIEW,
};
}
export const actions = {
acceptCall,
cancelCall,
@ -884,6 +897,7 @@ export const actions = {
toggleParticipants,
togglePip,
toggleSettings,
toggleSpeakerView,
};
export type ActionsType = typeof actions;
@ -974,6 +988,7 @@ export function reducer(
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
isInSpeakerView: false,
pip: false,
safetyNumberChangedUuids: [],
settingsDialogOpen: false,
@ -999,6 +1014,7 @@ export function reducer(
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
isInSpeakerView: false,
pip: false,
safetyNumberChangedUuids: [],
settingsDialogOpen: false,
@ -1019,6 +1035,7 @@ export function reducer(
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
isInSpeakerView: false,
pip: false,
safetyNumberChangedUuids: [],
settingsDialogOpen: false,
@ -1084,6 +1101,7 @@ export function reducer(
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
isInSpeakerView: false,
pip: false,
safetyNumberChangedUuids: [],
settingsDialogOpen: false,
@ -1409,6 +1427,24 @@ export function reducer(
};
}
if (action.type === TOGGLE_SPEAKER_VIEW) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn(
'Cannot toggle speaker view when there is no active call'
);
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
isInSpeakerView: !activeCallState.isInSpeakerView,
},
};
}
if (action.type === MARK_CALL_UNTRUSTED) {
const { activeCallState } = state;
if (!activeCallState) {

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -75,6 +75,7 @@ const mapStateToActiveCallProp = (
conversation,
hasLocalAudio: activeCallState.hasLocalAudio,
hasLocalVideo: activeCallState.hasLocalVideo,
isInSpeakerView: activeCallState.isInSpeakerView,
joinedAt: activeCallState.joinedAt,
pip: activeCallState.pip,
settingsDialogOpen: activeCallState.settingsDialogOpen,

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
@ -43,6 +43,7 @@ describe('calling duck', () => {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -98,6 +99,7 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -202,6 +204,7 @@ describe('calling duck', () => {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -578,6 +581,7 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -815,6 +819,7 @@ describe('calling duck', () => {
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -1050,6 +1055,7 @@ describe('calling duck', () => {
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,
@ -1120,6 +1126,23 @@ describe('calling duck', () => {
assert.isTrue(afterThreeToggles.activeCallState?.pip);
});
});
describe('toggleSpeakerView', () => {
const { toggleSpeakerView } = actions;
it('toggles speaker view', () => {
const afterOneToggle = reducer(
stateWithActiveGroupCall,
toggleSpeakerView()
);
const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView());
const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView());
assert.isTrue(afterOneToggle.activeCallState?.isInSpeakerView);
assert.isFalse(afterTwoToggles.activeCallState?.isInSpeakerView);
assert.isTrue(afterThreeToggles.activeCallState?.isInSpeakerView);
});
});
});
describe('helpers', () => {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
@ -40,6 +40,7 @@ describe('state/selectors/calling', () => {
conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
isInSpeakerView: false,
showParticipantsList: false,
safetyNumberChangedUuids: [],
pip: false,

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationType } from '../state/ducks/conversations';
@ -14,6 +14,7 @@ interface ActiveCallBaseType {
conversation: ConversationType;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
isInSpeakerView: boolean;
joinedAt?: number;
pip: boolean;
settingsDialogOpen: boolean;