= ({
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
+ toggleSpeakerView={toggleSpeakerView}
/>
{remoteParticipantsElement}
diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx
index 2e37f9e37..b9b2f9a1c 100644
--- a/ts/components/CallingHeader.tsx
+++ b/ts/components/CallingHeader.tsx
@@ -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 => (
{title ? (
@@ -80,6 +84,33 @@ export const CallingHeader = ({
/>
+ {isGroupCall && participantCount > 2 && toggleSpeakerView && (
+
+
+
+
+
+ )}
{canPip && (
diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx
index 5ed14ab1e..369722ed5 100644
--- a/ts/components/CallingPip.stories.tsx
+++ b/ts/components/CallingPip.stories.tsx
@@ -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,
diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx
index 86a669a0d..24ebc59dd 100644
--- a/ts/components/GroupCallRemoteParticipants.tsx
+++ b/ts/components/GroupCallRemoteParticipants.tsx
@@ -38,6 +38,7 @@ interface GridArrangement {
interface PropsType {
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
+ isInSpeakerView: boolean;
remoteParticipants: ReadonlyArray;
setGroupCallVideoRequest: (_: Array) => void;
}
@@ -68,6 +69,7 @@ interface PropsType {
export const GroupCallRemoteParticipants: React.FC = ({
getGroupCallVideoFrameSource,
i18n,
+ isInSpeakerView,
remoteParticipants,
setGroupCallVideoRequest,
}) => {
@@ -122,6 +124,14 @@ export const GroupCallRemoteParticipants: React.FC = ({
[remoteParticipants]
);
const gridParticipants: Array = 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 = ({
// 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 = useMemo(
() =>
sortedParticipants
diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts
index fba6f9864..cca5b5912 100644
--- a/ts/state/ducks/calling.ts
+++ b/ts/state/ducks/calling.ts
@@ -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;
+ 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) {
diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx
index cecd6d40f..f18e7cb37 100644
--- a/ts/state/smart/CallManager.tsx
+++ b/ts/state/smart/CallManager.tsx
@@ -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,
diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts
index 0846412bc..9025f7b1c 100644
--- a/ts/test-electron/state/ducks/calling_test.ts
+++ b/ts/test-electron/state/ducks/calling_test.ts
@@ -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', () => {
diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts
index 06a4d2bb4..6b7cf8570 100644
--- a/ts/test-electron/state/selectors/calling_test.ts
+++ b/ts/test-electron/state/selectors/calling_test.ts
@@ -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,
diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts
index 00a2aa6ba..a31033080 100644
--- a/ts/types/Calling.ts
+++ b/ts/types/Calling.ts
@@ -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;