diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7b4726bfcd..ec62034d51 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3663,13 +3663,26 @@ "messageformat": "Fullscreen call", "description": "Title for picture-in-picture toggle" }, - "icu:calling__switch-view--to-grid": { - "messageformat": "Switch to grid view", - "description": "Title for grid/speaker view toggle when on a call" + + "icu:calling__change-view": { + "messageformat": "Change view", + "description": "Tooltip for changing the in-call layout of remote participants in a group call" }, - "icu:calling__switch-view--to-speaker": { - "messageformat": "Switch to speaker view", - "description": "Title for grid/speaker view toggle when on a call" + "icu:calling__view_mode--paginated": { + "messageformat": "Grid view", + "description": "Label for option to view participants in a group call in a paginated grid view" + }, + "icu:calling__view_mode--overflow": { + "messageformat": "Sidebar view", + "description": "Label for option to view participants in a group call where videos that don't fit onto the page are put into a scrollable overflow sidebar" + }, + "icu:calling__view_mode--speaker": { + "messageformat": "Speaker view", + "description": "Label for option to view participants where only the current speaker's video is fully visible and all others are put into a scrollable overflow sidebar" + }, + "icu:calling__view_mode--updated": { + "messageformat": "View updated", + "description": "Toast shown whenver the calling view mode is changed (e.g. paginated view -> speaker view)" }, "icu:calling__hangup": { "messageformat": "Leave call", diff --git a/images/icons/v3/grid/overflow_view.svg b/images/icons/v3/grid/overflow_view.svg new file mode 100644 index 0000000000..31ed17fe09 --- /dev/null +++ b/images/icons/v3/grid/overflow_view.svg @@ -0,0 +1 @@ + diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 007a2aad50..061e7b6a82 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3598,6 +3598,9 @@ button.module-image__border-overlay:focus { height: var(--window-height); justify-content: center; position: fixed; + /* stylelint-disable-next-line liberty/use-logical-spec */ + left: 0; + top: 0; width: 100%; z-index: $z-index-calling; } @@ -3706,7 +3709,31 @@ button.module-image__border-overlay:focus { opacity: 0; } } +@mixin module-ongoing-call__controls--fade-in { + animation: { + name: module-ongoing-call__controls--fade-out; + duration: 1200ms; + timing-function: $ease-out-expo; + fill-mode: forwards; + } +} +@mixin module-ongoing-call__controls--fade-out { + animation: { + name: module-ongoing-call__controls--fade-out; + duration: 1200ms; + timing-function: $ease-out-expo; + fill-mode: forwards; + } + pointer-events: none; +} + +.module-ongoing-call__container--hide-controls { + .module-ongoing-call__prev-page, + .module-ongoing-call__next-page { + @include module-ongoing-call__controls--fade-out; + } +} .module-ongoing-call { $local-preview-width: 108px; $local-preview-height: 80px; @@ -3740,12 +3767,52 @@ button.module-image__border-overlay:focus { } } + &__next-page, + &__prev-page { + @include button-reset; + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 32px; + width: 32px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: $color-gray-78; + z-index: $z-index-above-above-base; + + &--arrow { + width: 20px; + height: 20px; + } + } + + &__next-page { + inset-inline-end: 4px; + &--arrow { + @include color-svg( + '../images/icons/v3/chevron/chevron-right.svg', + $color-white + ); + } + } + + &__prev-page { + inset-inline-start: 4px; + &--arrow { + @include color-svg( + '../images/icons/v3/chevron/chevron-left.svg', + $color-white + ); + } + } + &__header { background: linear-gradient($color-black-alpha-40, transparent); top: 0; width: 100%; z-index: $z-index-above-above-base; - -webkit-app-region: drag; } &__header-message { @@ -3767,6 +3834,14 @@ button.module-image__border-overlay:focus { margin-block-start: 24px; z-index: $z-index-above-base; + &__grid--wrapper { + margin-block-start: 26px; + margin-block-end: 16px; + margin-inline: 16px; + display: flex; + width: 100%; + } + &__grid { flex-grow: 1; position: relative; @@ -3775,7 +3850,7 @@ button.module-image__border-overlay:focus { &__overflow { flex: 0 0 auto; position: relative; - margin-inline: 16px; + margin-inline-end: 16px; &__inner { position: absolute; @@ -3875,6 +3950,34 @@ button.module-image__border-overlay:focus { } } + &__group-call--pagination-tile { + @include button-reset; + position: absolute; + border-radius: 10px; + background-color: $color-gray-78; + display: flex; + justify-content: center; + align-items: center; + @include font-body-1; + color: $color-gray-20; + &--next-arrow { + @include color-svg( + '../images/icons/v3/chevron/chevron-right.svg', + $color-gray-20 + ); + height: 16px; + width: 16px; + } + &--prev-arrow { + @include color-svg( + '../images/icons/v3/chevron/chevron-left.svg', + $color-gray-20 + ); + height: 16px; + width: 16px; + } + } + &__group-call-remote-participant { display: flex; justify-content: center; @@ -3883,9 +3986,8 @@ button.module-image__border-overlay:focus { line-height: 0; overflow: hidden; border-radius: 10px; - // stylelint-disable-next-line declaration-property-value-disallowed-list - transform: translate(0, 0); - transition: transform 200ms linear, width 200ms linear, height 200ms linear; + transition: top 200ms linear, inset-inline-start 200ms linear, + transform 200ms linear, width 200ms linear, height 200ms linear; &:after { content: ''; @@ -4047,21 +4149,11 @@ button.module-image__border-overlay:focus { } &__controls--fadeIn { - animation: { - name: module-ongoing-call__controls--fade-in; - duration: 400ms; - timing-function: $ease-out-expo; - fill-mode: forwards; - } + @include module-ongoing-call__controls--fade-in; } &__controls--fadeOut { - animation: { - name: module-ongoing-call__controls--fade-out; - duration: 1200ms; - timing-function: $ease-out-expo; - fill-mode: forwards; - } + @include module-ongoing-call__controls--fade-out; } } @@ -4079,6 +4171,9 @@ button.module-image__border-overlay:focus { &__button:last-child { margin-inline-end: 24px; } + .ContextMenu__container { + background: none; + } } .module-calling-pip { diff --git a/stylesheets/components/CallSettingsButton.scss b/stylesheets/components/CallSettingsButton.scss index 5a8f0911fa..0ebc27488a 100644 --- a/stylesheets/components/CallSettingsButton.scss +++ b/stylesheets/components/CallSettingsButton.scss @@ -34,7 +34,11 @@ @include CallSettingsButton-icon('../images/icons/v3/x/x.svg'); } -.CallSettingsButton__Icon--GridView { +.CallSettingsButton__Icon--OverflowView { + @include CallSettingsButton-icon('../images/icons/v3/grid/overflow_view.svg'); +} + +.CallSettingsButton__Icon--PaginatedView { @include CallSettingsButton-icon('../images/icons/v3/grid/grid.svg'); } diff --git a/stylesheets/components/CallingToast.scss b/stylesheets/components/CallingToast.scss index 15ae45880d..279e759b8f 100644 --- a/stylesheets/components/CallingToast.scss +++ b/stylesheets/components/CallingToast.scss @@ -47,3 +47,13 @@ /* stylelint-disable-next-line liberty/use-logical-spec */ left: 0; } + +.CallingToast__viewChanged { + display: flex; + align-items: center; + gap: 8px; + &__icon { + width: 18px; + height: 18px; + } +} diff --git a/stylesheets/components/ContextMenu.scss b/stylesheets/components/ContextMenu.scss index dfb60db83a..f93a0cde7d 100644 --- a/stylesheets/components/ContextMenu.scss +++ b/stylesheets/components/ContextMenu.scss @@ -39,14 +39,38 @@ align-items: center; display: flex; justify-content: space-between; - padding-block: 7px; - padding-inline: 12px; min-width: 150px; width: 100%; &--container { display: flex; align-items: center; + padding-block: 7px; + padding-inline: 12px; + &--with-selection { + padding-inline-start: 8px; + padding-inline-end: 24px; + &::before { + content: ''; + width: 16px; + height: 16px; + margin-inline-end: 8px; + } + } + &--selected::before { + @include light-theme { + @include color-svg( + '../images/icons/v3/check/check.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/check/check.svg', + $color-white + ); + } + } } &--icon { @@ -55,18 +79,8 @@ width: 16px; } - &--selected { - height: 12px; - margin-block: 0; - margin-inline: 6px; - width: 16px; - - @include light-theme { - @include color-svg('../images/icons/v3/check/check.svg', $color-black); - } - @include dark-theme { - @include color-svg('../images/icons/v3/check/check.svg', $color-white); - } + &--container--with-selection &--icon { + margin-inline-end: 12px; } &--title { @@ -96,12 +110,10 @@ } } } - - &__popper--single-item &__option { + &__popper--single-item &__option--container { padding-block: 12px; } - - &__divider { + &__popper--single-item &__divider { border-style: solid; border-width: 0 0 1px 0; margin-top: 2px; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index a9d43496c3..72cd9fcc95 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -47,7 +47,7 @@ const getCommonActiveCallData = () => ({ hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, outgoingRing: true, pip: false, settingsDialogOpen: false, @@ -61,6 +61,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ bounceAppIconStart: action('bounce-app-icon-start'), bounceAppIconStop: action('bounce-app-icon-stop'), cancelCall: action('cancel-call'), + changeCallView: action('change-call-view'), closeNeedPermissionScreen: action('close-need-permission-screen'), declineCall: action('decline-call'), getGroupCallVideoFrameSource: (_: string, demuxId: number) => @@ -102,7 +103,6 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ 'toggle-screen-recording-permissions-dialog' ), toggleSettings: action('toggle-settings'), - toggleSpeakerView: action('toggle-speaker-view'), isConversationTooBigToRing: false, pauseVoiceNotePlayer: action('pause-audio-player'), }); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index ac746fae6f..bc2485afef 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -15,6 +15,7 @@ import type { SafetyNumberProps } from './SafetyNumberChangeDialog'; import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog'; import type { ActiveCallType, + CallViewMode, GroupCallVideoRequest, PresentedSource, } from '../types/Calling'; @@ -49,6 +50,7 @@ export type PropsType = { activeCall?: ActiveCallType; availableCameras: Array; cancelCall: (_: CancelCallType) => void; + changeCallView: (mode: CallViewMode) => void; closeNeedPermissionScreen: () => void; getGroupCallVideoFrameSource: ( conversationId: string, @@ -103,7 +105,6 @@ export type PropsType = { togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; - toggleSpeakerView: () => void; isConversationTooBigToRing: boolean; pauseVoiceNotePlayer: () => void; }; @@ -116,6 +117,7 @@ function ActiveCallManager({ activeCall, availableCameras, cancelCall, + changeCallView, closeNeedPermissionScreen, hangUpActiveCall, i18n, @@ -143,7 +145,6 @@ function ActiveCallManager({ togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, - toggleSpeakerView, pauseVoiceNotePlayer, }: ActiveCallManagerPropsType): JSX.Element { const { @@ -322,6 +323,7 @@ function ActiveCallManager({ <> {presentingSourcesAvailable && presentingSourcesAvailable.length ? ( ({ activeCall: createActiveCallProp(overrideProps), + changeCallView: action('change-call-view'), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getPresentingSources: action('get-presenting-sources'), hangUpActiveCall: action('hang-up'), @@ -169,7 +173,6 @@ const createProps = ( 'toggle-screen-recording-permissions-dialog' ), toggleSettings: action('toggle-settings'), - toggleSpeakerView: action('toggle-speaker-view'), }); function CallScreen(props: ReturnType): JSX.Element { @@ -301,24 +304,66 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ hasRemoteVideo: index % 4 !== 0, presenting: false, sharingScreen: false, - videoAspectRatio: 1.3, + videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6, ...getDefaultConversationWithServiceId({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, title: `Participant ${index + 1}`, }), })); -export function GroupCallMany(): JSX.Element { +export function GroupCallManyPaginated(): JSX.Element { + const props = createProps({ + callMode: CallMode.Group, + remoteParticipants: allRemoteParticipants, + viewMode: CallViewMode.Paginated, + }); + + return ; +} +export function GroupCallManyPaginatedEveryoneTalking(): JSX.Element { + const [props] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants: allRemoteParticipants, + viewMode: CallViewMode.Paginated, + }) + ); + + const activeCall = useMakeEveryoneTalk( + props.activeCall as ActiveGroupCallType + ); + + return ; +} + +export function GroupCallManyOverflow(): JSX.Element { return ( ); } +export function GroupCallManyOverflowEveryoneTalking(): JSX.Element { + const [props] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants: allRemoteParticipants, + viewMode: CallViewMode.Overflow, + }) + ); + + const activeCall = useMakeEveryoneTalk( + props.activeCall as ActiveGroupCallType + ); + + return ; +} + export function GroupCallSpeakerView(): JSX.Element { return ( ); } + +function useMakeEveryoneTalk( + activeCall: ActiveGroupCallType, + frequency = 2000 +) { + const [call, setCall] = React.useState(activeCall); + React.useEffect(() => { + const interval = setInterval(() => { + const idxToStartSpeaking = Math.floor( + Math.random() * call.remoteParticipants.length + ); + + const demuxIdToStartSpeaking = ( + call.remoteParticipants[ + idxToStartSpeaking + ] as GroupCallRemoteParticipantType + ).demuxId; + + const remoteAudioLevels = new Map(); + + for (const [demuxId] of call.remoteAudioLevels.entries()) { + if (demuxId === demuxIdToStartSpeaking) { + remoteAudioLevels.set(demuxId, 1); + } else { + remoteAudioLevels.set(demuxId, 0); + } + } + setCall(state => ({ + ...state, + remoteParticipants: state.remoteParticipants.map((part, idx) => { + return { + ...part, + hasRemoteAudio: + idx === idxToStartSpeaking ? true : part.hasRemoteAudio, + speakerTime: + idx === idxToStartSpeaking + ? Date.now() + : (part as GroupCallRemoteParticipantType).speakerTime, + }; + }), + remoteAudioLevels, + })); + }, frequency); + return () => clearInterval(interval); + }, [frequency, call]); + return call; +} diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index a20f6744b7..ba72fe605f 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -14,7 +14,7 @@ import type { SetRendererCanvasType, } from '../state/ducks/calling'; import { Avatar, AvatarSize } from './Avatar'; -import { CallingHeader } from './CallingHeader'; +import { CallingHeader, getCallViewIconClassname } from './CallingHeader'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingButton, CallingButtonType } from './CallingButton'; import { Button, ButtonVariant } from './Button'; @@ -46,7 +46,10 @@ import type { LocalizerType } from '../types/Util'; import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; import { missingCaseError } from '../util/missingCaseError'; import * as KeyboardLayout from '../services/keyboardLayout'; -import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; +import { + usePresenter, + useActivateSpeakerViewOnPresenting, +} from '../hooks/useActivateSpeakerViewOnPresenting'; import { CallingAudioIndicator, SPEAKING_LINGER_MS, @@ -57,6 +60,8 @@ import { } from '../hooks/useKeyboardShortcuts'; import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting'; +import { usePrevious } from '../hooks/usePrevious'; +import { useCallingToasts } from './CallingToast'; export type PropsType = { activeCall: ActiveCallType; @@ -83,7 +88,7 @@ export type PropsType = { togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; - toggleSpeakerView: () => void; + changeCallView: (mode: CallViewMode) => void; }; export const isInSpeakerView = ( @@ -123,6 +128,7 @@ function CallDuration({ export function CallScreen({ activeCall, + changeCallView, getGroupCallVideoFrameSource, getPresentingSources, groupMembers, @@ -143,7 +149,6 @@ export function CallScreen({ togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, - toggleSpeakerView, }: PropsType): JSX.Element { const { conversation, @@ -253,6 +258,7 @@ export function CallScreen({ useReconnectingToast({ activeCall, i18n }); useScreenSharingStoppedToast({ activeCall, i18n }); + useViewModeChangedToast({ activeCall, i18n }); const currentPresenter = remoteParticipants.find( participant => participant.presenting @@ -315,9 +321,9 @@ export function CallScreen({ activeCall.connectionState === GroupCallConnectionState.Connected; remoteParticipantsElement = ( { setShowControls(true); @@ -493,14 +500,14 @@ export function CallScreen({ className={classNames('module-ongoing-call__header', controlsFadeClass)} > {isRinging && ( @@ -625,3 +632,56 @@ function renderDuration(ms: number): string { } return `${mins}:${secs}`; } + +function useViewModeChangedToast({ + activeCall, + i18n, +}: { + activeCall: ActiveCallType; + i18n: LocalizerType; +}): void { + const { viewMode } = activeCall; + const previousViewMode = usePrevious(viewMode, viewMode); + const presenterAci = usePresenter(activeCall.remoteParticipants); + + const VIEW_MODE_CHANGED_TOAST_KEY = 'view-mode-changed'; + const { showToast, hideToast } = useCallingToasts(); + + useEffect(() => { + if (viewMode !== previousViewMode) { + if ( + // If this is an automated change to presentation mode, don't show toast + viewMode === CallViewMode.Presentation || + // if this is an automated change away from presentation mode, don't show toast + (previousViewMode === CallViewMode.Presentation && !presenterAci) + ) { + return; + } + + hideToast(VIEW_MODE_CHANGED_TOAST_KEY); + showToast({ + key: VIEW_MODE_CHANGED_TOAST_KEY, + content: ( +
+ + {i18n('icu:calling__view_mode--updated')} +
+ ), + autoClose: true, + }); + } + }, [ + showToast, + hideToast, + i18n, + activeCall, + viewMode, + previousViewMode, + presenterAci, + ]); +} diff --git a/ts/components/CallingHeader.stories.tsx b/ts/components/CallingHeader.stories.tsx index 251c5dbde3..1e83bab26c 100644 --- a/ts/components/CallingHeader.stories.tsx +++ b/ts/components/CallingHeader.stories.tsx @@ -8,6 +8,7 @@ import type { PropsType } from './CallingHeader'; import { CallingHeader } from './CallingHeader'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; +import { CallViewMode } from '../types/Calling'; const i18n = setupI18n('en', enMessages); @@ -26,7 +27,8 @@ export default { participantCount: 0, title: 'With Someone', togglePip: action('toggle-pip'), - toggleSettings: action('toggle-settings'), + callViewMode: CallViewMode.Paginated, + changeCallView: action('change-call-view'), }, } satisfies Meta; diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx index 0d0100fbb2..b2c7d08834 100644 --- a/ts/components/CallingHeader.tsx +++ b/ts/components/CallingHeader.tsx @@ -2,15 +2,17 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; -import React from 'react'; import classNames from 'classnames'; +import React from 'react'; import type { LocalizerType } from '../types/Util'; +import { CallViewMode } from '../types/Calling'; import { Tooltip } from './Tooltip'; import { Theme } from '../util/theme'; +import { ContextMenu } from './ContextMenu'; export type PropsType = { + callViewMode?: CallViewMode; i18n: LocalizerType; - isInSpeakerView?: boolean; isGroupCall?: boolean; message?: ReactNode; onCancel?: () => void; @@ -18,12 +20,13 @@ export type PropsType = { title?: string; togglePip?: () => void; toggleSettings: () => void; - toggleSpeakerView?: () => void; + changeCallView?: (mode: CallViewMode) => void; }; export function CallingHeader({ + callViewMode, + changeCallView, i18n, - isInSpeakerView, isGroupCall = false, message, onCancel, @@ -31,7 +34,6 @@ export function CallingHeader({ title, togglePip, toggleSettings, - toggleSpeakerView, }: PropsType): JSX.Element { return (
@@ -42,39 +44,63 @@ export function CallingHeader({
{message}
) : null}
- {isGroupCall && participantCount > 2 && toggleSpeakerView && ( -
- - - -
- )} + +
+ +
+
+ +
+ )}
); } + +const CALL_VIEW_MODE_ICON_CLASSNAMES: Record = { + [CallViewMode.Overflow]: 'CallSettingsButton__Icon--OverflowView', + [CallViewMode.Paginated]: 'CallSettingsButton__Icon--PaginatedView', + [CallViewMode.Speaker]: 'CallSettingsButton__Icon--SpeakerView', + [CallViewMode.Presentation]: 'CallSettingsButton__Icon--SpeakerView', +}; +export function getCallViewIconClassname(viewMode: CallViewMode): string { + return CALL_VIEW_MODE_ICON_CLASSNAMES[viewMode]; +} diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 2cfc22afc1..06049489b7 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -46,7 +46,7 @@ const getCommonActiveCallData = (overrides: Overrides) => ({ hasLocalAudio: overrides.hasLocalAudio ?? true, hasLocalVideo: overrides.hasLocalVideo ?? false, localAudioLevel: overrides.localAudioLevel ?? 0, - viewMode: overrides.viewMode ?? CallViewMode.Grid, + viewMode: overrides.viewMode ?? CallViewMode.Paginated, joinedAt: Date.now(), outgoingRing: true, pip: true, diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 262935a528..496f73c6ab 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -203,6 +203,7 @@ export function ContextMenu({ const getClassName = getClassNamesFor('ContextMenu', moduleClassName); const optionElements = new Array(); + const isAnyOptionSelected = typeof value !== 'undefined'; for (const [index, option] of menuOptions.entries()) { const previous = menuOptions[index - 1]; @@ -229,6 +230,7 @@ export function ContextMenu({ closeCurrentOpenContextMenu = undefined; }; + const isOptionSelected = isAnyOptionSelected && value === option.value; optionElements.push( ); } @@ -308,6 +315,7 @@ export function ContextMenu({
diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index 8902c968c5..70a00d04b4 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useState, useMemo, useEffect } from 'react'; -import { takeWhile, clamp, chunk, maxBy, flatten, noop } from 'lodash'; +import { clamp, chunk, maxBy, flatten, noop } from 'lodash'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; import { @@ -13,6 +13,7 @@ import type { GroupCallRemoteParticipantType, GroupCallVideoRequest, } from '../types/Calling'; +import { CallViewMode } from '../types/Calling'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; import type { LocalizerType } from '../types/Util'; import { usePageVisibility } from '../hooks/usePageVisibility'; @@ -25,11 +26,14 @@ import * as setUtil from '../util/setUtil'; import * as log from '../logging/log'; import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants'; import { SizeObserver } from '../hooks/useSizeObserver'; +import { strictAssert } from '../util/assert'; -const MIN_RENDERED_HEIGHT = 180; -const PARTICIPANT_MARGIN = 10; +const SMALL_TILES_MIN_HEIGHT = 80; +const LARGE_TILES_MIN_HEIGHT = 200; +const PARTICIPANT_MARGIN = 12; const TIME_TO_STOP_REQUESTING_VIDEO_WHEN_PAGE_INVISIBLE = 20 * SECOND; - +const PAGINATION_BUTTON_ASPECT_RATIO = 1; +const MAX_PARTICIPANTS_PER_PAGE = 49; // 49 remote + 1 self-video = 50 total // We scale our video requests down for performance. This number is somewhat arbitrary. const VIDEO_REQUEST_SCALAR = 0.75; @@ -39,15 +43,24 @@ type Dimensions = { }; type GridArrangement = { - rows: Array>; + rows: Array>; scalar: number; }; +type PaginationButtonType = { + isPaginationButton: true; + videoAspectRatio: number; + paginationButtonType: 'prev' | 'next'; + numParticipants: number; +}; +type ParticipantTileType = + | GroupCallRemoteParticipantType + | PaginationButtonType; type PropsType = { + callViewMode: CallViewMode; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; i18n: LocalizerType; isCallReconnecting: boolean; - isInSpeakerView: boolean; remoteParticipants: ReadonlyArray; setGroupCallVideoRequest: ( _: Array, @@ -72,37 +85,43 @@ enum VideoRequestMode { // * Participants are arranged in 0 or more rows. // * Each row is the same height, but each participant may have a different width. // * It's possible, on small screens with lots of participants, to have participants -// removed from the grid. This is because participants have a minimum rendered height. +// removed from the grid, or on subsequent pages. This is because participants +// have a minimum rendered height. +// * Participant videos may have different aspect ratios +// * We want to ensure that presenters and recent speakers are shown on the first page, +// but we also want to minimize tiles jumping around as much as possible. // // There should be more specific comments throughout, but the high-level steps are: // -// 1. Figure out the maximum number of possible rows that could fit on the screen; this is -// `maxRowCount`. -// 2. Split the participants into two groups: ones in the main grid and ones in the -// overflow area. The grid should prioritize participants who have recently spoken. -// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`), -// distribute participants across the rows at the minimum height. Then find the -// "scalar": how much can we scale these boxes up while still fitting them on the -// screen? The biggest scalar wins as the "best arrangement". -// 4. Lay out this arrangement on the screen. +// 1. Figure out the maximum number of possible rows that could fit on a page; this is +// `maxRowsPerPage`. +// 2. Sort the participants in priority order: we want to fit presenters and recent +// speakers in the grid first +// 3. Figure out which participants should go on each page -- for non-paginated views, +// this is just one page, but for paginated views, we could have many pages. The +// general idea here is to fill up each page row-by-row, with each video as small +// as we allow. +// 4. Try to distribute the videos throughout the grid to find the largest "scalar": +// how much can we scale these boxes up while still fitting them on the screen? +// The biggest scalar wins as the "best arrangement". +// 5. Lay out this arrangement on the screen. + export function GroupCallRemoteParticipants({ + callViewMode, getGroupCallVideoFrameSource, i18n, isCallReconnecting, - isInSpeakerView, remoteParticipants, setGroupCallVideoRequest, remoteAudioLevels, }: PropsType): JSX.Element { - const [containerDimensions, setContainerDimensions] = useState({ - width: 0, - height: 0, - }); const [gridDimensions, setGridDimensions] = useState({ width: 0, height: 0, }); + const [pageIndex, setPageIndex] = useState(0); + const devicePixelRatio = useDevicePixelRatio(); const getFrameBuffer = useGetCallingFrameBuffer(); @@ -110,191 +129,224 @@ export function GroupCallRemoteParticipants({ const { invisibleDemuxIds, onParticipantVisibilityChanged } = useInvisibleParticipants(remoteParticipants); - // 1. Figure out the maximum number of possible rows that could fit on the screen. - // - // We choose the smaller of these two options: - // - // - The number of participants, which means there'd be one participant per row. - // - The number of possible rows in the container, assuming all participants were - // rendered at minimum height. Doesn't rely on the number of participants—it's some - // simple division. - // - // Could be 0 if (a) there are no participants (b) the container's height is small. - const maxRowCount = Math.min( - remoteParticipants.length, - Math.floor( - containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN) - ) + const minRenderedHeight = + callViewMode === CallViewMode.Paginated + ? SMALL_TILES_MIN_HEIGHT + : LARGE_TILES_MIN_HEIGHT; + + const isInSpeakerView = + callViewMode === CallViewMode.Speaker || + callViewMode === CallViewMode.Presentation; + + const isInPaginationView = callViewMode === CallViewMode.Paginated; + const shouldShowOverflow = !isInPaginationView; + + const maxRowWidth = gridDimensions.width; + const maxGridHeight = gridDimensions.height; + + // 1. Figure out the maximum number of possible rows that could fit on the page. + // Could be 0 if (a) there are no participants (b) the container's height is small. + const maxRowsPerPage = Math.floor( + maxGridHeight / (minRenderedHeight + PARTICIPANT_MARGIN) ); - // 2. Split participants into two groups: ones in the main grid and ones in the overflow - // sidebar. - // - // We start by sorting by `presenting` first since presenters should be on the main grid - // then we sort by `speakerTime` so that the most recent speakers are next in - // line for the main grid. Then we split the list in two: one for the grid and one for - // the overflow area. - // - // Once we've sorted participants into their respective groups, we sort them on - // something stable (the `demuxId`, but we could choose something else) so that people - // don't jump around within the group. - // - // These are primarily memoized for clarity, not performance. - const sortedParticipants: Array = useMemo( - () => - remoteParticipants - .concat() - .sort( - (a, b) => - Number(b.presenting || 0) - Number(a.presenting || 0) || - (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) - ), - [remoteParticipants] - ); - const gridParticipants: Array = - useMemo(() => { - if (!sortedParticipants.length) { - return []; - } + // 2. Sort the participants in priority order: by `presenting` first, since presenters + // should be on the main grid, then by `speakerTime` so that the most recent speakers + // are next in line for the first pages of the grid + const prioritySortedParticipants: Array = + useMemo( + () => + remoteParticipants + .concat() + .sort( + (a, b) => + Number(b.presenting || 0) - Number(a.presenting || 0) || + (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) + ), + [remoteParticipants] + ); - 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. - const maxTotalWidth = maxRowCount * containerDimensions.width; - - // 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(candidateParticipants, remoteParticipant => { - totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT; - return totalWidth < maxTotalWidth; - }).sort(stableParticipantComparator); - }, [ - containerDimensions.width, - isInSpeakerView, - maxRowCount, - sortedParticipants, - ]); - const overflowedParticipants: Array = useMemo( - () => - sortedParticipants - .slice(gridParticipants.length) - .sort(stableParticipantComparator), - [sortedParticipants, gridParticipants.length] - ); - - // 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`), - // distribute participants across the rows at the minimum height. Then find the - // "scalar": how much can we scale these boxes up while still fitting them on the - // screen? The biggest scalar wins as the "best arrangement". - const gridArrangement: GridArrangement = useMemo(() => { - let bestArrangement: GridArrangement = { - scalar: -1, - rows: [], - }; - - if (!gridParticipants.length) { - return bestArrangement; + // 3. Layout the participants on each page. The general algorithm is: first, try to fill + // up each page with as many participants as possible at the smallest acceptable video + // height. Second, sort the participants that fit on each page by a stable sort order, + // and make sure they still fit on the page! Third, add tiles at the beginning and end + // of each page (if paginated) to act as back and next buttons. + const gridParticipantsByPage: Array = useMemo(() => { + if (!prioritySortedParticipants.length) { + return []; } - for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) { - // We do something pretty naïve here and chunk the grid's participants into rows. - // For example, if there were 12 grid participants and `rowCount === 3`, there - // would be 4 participants per row. - // - // This naïve chunking is suboptimal in terms of absolute best fit, but it is much - // faster and simpler than trying to do this perfectly. In practice, this works - // fine in the UI from our testing. - const numberOfParticipantsInRow = Math.ceil( - gridParticipants.length / rowCount - ); - const rows = chunk(gridParticipants, numberOfParticipantsInRow); - - // We need to find the scalar for this arrangement. Imagine that we have these - // participants at the minimum heights, and we want to scale everything up until - // it's about to overflow. - // - // We don't want it to overflow horizontally or vertically, so we calculate a - // "width scalar" and "height scalar" and choose the smaller of the two. (Choosing - // the LARGER of the two could cause overflow.) - const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight); - if (!widestRow) { - log.error('Unable to find the widest row, which should be impossible'); - continue; - } - const widthScalar = - (gridDimensions.width - (widestRow.length + 1) * PARTICIPANT_MARGIN) / - totalRemoteParticipantWidthAtMinHeight(widestRow); - const heightScalar = - (gridDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) / - (rowCount * MIN_RENDERED_HEIGHT); - const scalar = Math.min(widthScalar, heightScalar); - - // If this scalar is the best one so far, we use that. - if (scalar > bestArrangement.scalar) { - bestArrangement = { scalar, rows }; - } + if (!maxRowsPerPage) { + return []; } - return bestArrangement; + if (isInSpeakerView) { + return [ + { + rows: [[prioritySortedParticipants[0]]], + hasSpaceRemaining: false, + numParticipants: 1, + }, + ]; + } + + return getGridParticipantsByPage({ + participants: prioritySortedParticipants, + maxRowWidth, + maxPages: isInPaginationView ? Infinity : 1, + maxRowsPerPage, + minRenderedHeight, + maxParticipantsPerPage: MAX_PARTICIPANTS_PER_PAGE, + currentPage: pageIndex, + }); }, [ - gridParticipants, - maxRowCount, - gridDimensions.width, - gridDimensions.height, + maxRowWidth, + isInPaginationView, + isInSpeakerView, + maxRowsPerPage, + minRenderedHeight, + pageIndex, + prioritySortedParticipants, ]); - // 4. Lay out this arrangement on the screen. - const gridParticipantHeight = Math.floor( - gridArrangement.scalar * MIN_RENDERED_HEIGHT + // Make sure we're not on a page that no longer exists (e.g. if people left the call) + if ( + pageIndex >= gridParticipantsByPage.length && + gridParticipantsByPage.length > 0 + ) { + setPageIndex(gridParticipantsByPage.length - 1); + } + + const totalParticipantsInGrid = gridParticipantsByPage.reduce( + (pageCount, { numParticipants }) => pageCount + numParticipants, + 0 + ); + + // In speaker or overflow views, not all participants will be on the grid; they'll + // get put in the overflow zone. + const overflowedParticipants: Array = useMemo( + () => + isInPaginationView + ? [] + : prioritySortedParticipants + .slice(totalParticipantsInGrid) + .sort(stableParticipantComparator), + [isInPaginationView, prioritySortedParticipants, totalParticipantsInGrid] + ); + + const participantsOnOtherPages = useMemo( + () => + gridParticipantsByPage + .map((page, index) => { + if (index === pageIndex) { + return []; + } + return page.rows.flat(); + }) + .flat() + .filter(isGroupCallRemoteParticipant), + [gridParticipantsByPage, pageIndex] + ); + + const currentPage = gridParticipantsByPage.at(pageIndex) ?? { + rows: [], + }; + + // 4. Try to arrange the current page such that we can scale the videos up + // as much as possible. + const gridArrangement = arrangeParticipantsInGrid({ + participantsInRows: currentPage.rows, + maxRowsPerPage, + maxRowWidth, + maxGridHeight, + minRenderedHeight, + }); + + const nextPage = () => { + setPageIndex(index => index + 1); + }; + + const prevPage = () => { + setPageIndex(index => Math.max(0, index - 1)); + }; + + // 5. Lay out the current page on the screen. + const gridParticipantHeight = Math.round( + gridArrangement.scalar * minRenderedHeight ); const gridParticipantHeightWithMargin = gridParticipantHeight + PARTICIPANT_MARGIN; const gridTotalRowHeightWithMargin = - gridParticipantHeightWithMargin * gridArrangement.rows.length; - const gridTopOffset = Math.floor( - (gridDimensions.height - gridTotalRowHeightWithMargin) / 2 + gridParticipantHeightWithMargin * gridArrangement.rows.length - + PARTICIPANT_MARGIN; + const gridTopOffset = Math.max( + 0, + Math.round((gridDimensions.height - gridTotalRowHeightWithMargin) / 2) ); const rowElements: Array> = gridArrangement.rows.map( - (remoteParticipantsInRow, index) => { + (tiles, index) => { const top = gridTopOffset + index * gridParticipantHeightWithMargin; const totalRowWidthWithoutMargins = - totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) * + totalRowWidthAtHeight(tiles, minRenderedHeight) * gridArrangement.scalar; const totalRowWidth = - totalRowWidthWithoutMargins + - PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1); - const leftOffset = Math.floor((gridDimensions.width - totalRowWidth) / 2); + totalRowWidthWithoutMargins + PARTICIPANT_MARGIN * (tiles.length - 1); + const leftOffset = Math.max( + 0, + Math.round((gridDimensions.width - totalRowWidth) / 2) + ); let rowWidthSoFar = 0; - return remoteParticipantsInRow.map(remoteParticipant => { - const { demuxId, videoAspectRatio } = remoteParticipant; - - const audioLevel = remoteAudioLevels.get(demuxId) ?? 0; - - const renderedWidth = Math.floor( - videoAspectRatio * gridParticipantHeight - ); + return tiles.map(tile => { const left = rowWidthSoFar + leftOffset; + const renderedWidth = Math.round( + tile.videoAspectRatio * gridParticipantHeight + ); + rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN; + if (isPaginationButton(tile)) { + const isNextButton = tile.paginationButtonType === 'next'; + const isPrevButton = tile.paginationButtonType === 'prev'; + return ( + + ); + } + return ( { - let scalar: number; - if (participant.sharingScreen) { - // We want best-resolution video if someone is sharing their screen. - scalar = Math.max(devicePixelRatio, 1); - } else { - scalar = VIDEO_REQUEST_SCALAR; - } - return { - demuxId: participant.demuxId, - width: clamp( - Math.floor( - gridParticipantHeight * participant.videoAspectRatio * scalar + ...currentPage.rows + .flat() + // Filter out any next/previous page buttons + .filter(isGroupCallRemoteParticipant) + .map(participant => { + let scalar: number; + if (participant.sharingScreen) { + // We want best-resolution video if someone is sharing their screen. + scalar = Math.max(devicePixelRatio, 1); + } else { + scalar = VIDEO_REQUEST_SCALAR; + } + return { + demuxId: participant.demuxId, + width: clamp( + Math.round( + gridParticipantHeight * + participant.videoAspectRatio * + scalar + ), + 1, + MAX_FRAME_WIDTH ), - 1, - MAX_FRAME_WIDTH - ), - height: clamp( - Math.floor(gridParticipantHeight * scalar), - 1, - MAX_FRAME_HEIGHT - ), - }; - }), + height: clamp( + Math.round(gridParticipantHeight * scalar), + 1, + MAX_FRAME_HEIGHT + ), + }; + }), + ...participantsOnOtherPages.map(nonRenderedRemoteParticipant), ...overflowedParticipants.map(participant => { if (invisibleDemuxIds.has(participant.demuxId)) { return nonRenderedRemoteParticipant(participant); @@ -349,12 +408,12 @@ export function GroupCallRemoteParticipants({ return { demuxId: participant.demuxId, width: clamp( - Math.floor(OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR), + Math.round(OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR), 1, MAX_FRAME_WIDTH ), height: clamp( - Math.floor( + Math.round( (OVERFLOW_PARTICIPANT_WIDTH / participant.videoAspectRatio) * VIDEO_REQUEST_SCALAR ), @@ -384,58 +443,79 @@ export function GroupCallRemoteParticipants({ videoRequest = remoteParticipants.map(nonRenderedRemoteParticipant); break; } - setGroupCallVideoRequest( videoRequest, clamp(gridParticipantHeight, 0, MAX_FRAME_HEIGHT) ); }, [ devicePixelRatio, + currentPage.rows, gridParticipantHeight, - gridParticipants, invisibleDemuxIds, overflowedParticipants, remoteParticipants, setGroupCallVideoRequest, videoRequestMode, + participantsOnOtherPages, ]); return ( - { - setContainerDimensions(size); - }} - > - {containerRef => ( -
- { - setGridDimensions(size); - }} - > - {gridRef => ( -
- {flatten(rowElements)} -
- )} -
+
+
+ { + setGridDimensions(size); + }} + > + {gridRef => ( +
+ {flatten(rowElements)} - -
- )} -
+ {isInPaginationView && ( + <> + {pageIndex > 0 ? ( + + ) : null} + {pageIndex < gridParticipantsByPage.length - 1 ? ( + + ) : null} + + )} +
+ )} + +
+ + {shouldShowOverflow && overflowedParticipants.length > 0 ? ( + + ) : null} +
); } @@ -520,19 +600,425 @@ function useVideoRequestMode(): VideoRequestMode { return result; } -function totalRemoteParticipantWidthAtMinHeight( - remoteParticipants: ReadonlyArray +function totalRowWidthAtHeight( + participantsInRow: ReadonlyArray< + Pick + >, + height: number ): number { - return remoteParticipants.reduce( - (result, { videoAspectRatio }) => - result + videoAspectRatio * MIN_RENDERED_HEIGHT, + return participantsInRow.reduce( + (result, participant) => + result + participantWidthAtHeight(participant, height), 0 ); } +function participantWidthAtHeight( + participant: Pick, + height: number +) { + return participant.videoAspectRatio * height; +} + function stableParticipantComparator( a: Readonly<{ demuxId: number }>, b: Readonly<{ demuxId: number }> ): number { return a.demuxId - b.demuxId; } + +type ParticipantsInPageType< + T extends { videoAspectRatio: number } = ParticipantTileType +> = { + rows: Array>; + numParticipants: number; +}; + +type PageLayoutPropsType = { + maxRowWidth: number; + minRenderedHeight: number; + maxRowsPerPage: number; + maxParticipantsPerPage: number; +}; +function getGridParticipantsByPage({ + participants, + maxPages, + currentPage, + ...pageLayoutProps +}: PageLayoutPropsType & { + participants: Array; + maxPages: number; + currentPage?: number; +}): Array { + if (!participants.length) { + return []; + } + + const pages: Array = []; + + function getTotalParticipantsOnGrid() { + return pages.reduce((count, page) => count + page.numParticipants, 0); + } + + let remainingParticipants = [...participants]; + while (remainingParticipants.length) { + if (currentPage === pages.length - 1) { + // Optimization: we can stop early, we don't have to lay out the remainder of the + // pages + pages.push({ + rows: [remainingParticipants], + numParticipants: remainingParticipants.length, + }); + return pages; + } + + const nextPageInPriorityOrder = getNextPage({ + participants: remainingParticipants, + leaveRoomForPrevPageButton: pages.length > 0, + leaveRoomForNextPageButton: pages.length + 1 < maxPages, + ...pageLayoutProps, + }); + + // We got the next page, but it's in priority order; let's see if these participants + // also fit in sorted order + const priorityParticipantsOnNextPage = nextPageInPriorityOrder.rows.flat(); + let sortedParticipantsHopingToFitOnPage = [ + ...priorityParticipantsOnNextPage, + ].sort(stableParticipantComparator); + let nextPageInSortedOrder = getNextPage({ + participants: sortedParticipantsHopingToFitOnPage, + leaveRoomForPrevPageButton: pages.length > 0, + leaveRoomForNextPageButton: pages.length + 1 < maxPages, + isSubsetOfAllParticipants: + sortedParticipantsHopingToFitOnPage.length < + remainingParticipants.length, + ...pageLayoutProps, + }); + + let nextPage: ParticipantsInPageType | undefined; + + if ( + nextPageInSortedOrder.numParticipants === + nextPageInPriorityOrder.numParticipants + ) { + // Great, we're able to show everyone. It's possible that there is now extra space + // and we could show more people, but let's leave it here for simplicity + nextPage = nextPageInSortedOrder; + } else { + // We weren't able to fit everyone. Let's remove the least-prioritized person and + // try again. It's pretty unlikely this will take more than 1 attempt, but + // let's take more and more participants off the screen if it takes a lot of + // attempts so we don't have to iterate dozens of times. + const PARTICIPANTS_TO_REMOVE_PER_ATTEMPT = [1, 1, 1, 2, 5]; + const MAX_ATTEMPTS = 5; + let attemptNumber = 0; + + while ( + sortedParticipantsHopingToFitOnPage.length && + attemptNumber < MAX_ATTEMPTS + ) { + const numLeastPrioritizedParticipantsToRemove = + PARTICIPANTS_TO_REMOVE_PER_ATTEMPT[ + Math.min( + attemptNumber, + PARTICIPANTS_TO_REMOVE_PER_ATTEMPT.length - 1 + ) + ]; + + const leastPrioritizedParticipantIds = new Set( + priorityParticipantsOnNextPage + .splice( + -1 * numLeastPrioritizedParticipantsToRemove, + numLeastPrioritizedParticipantsToRemove + ) + .map(participant => participant.demuxId) + ); + + sortedParticipantsHopingToFitOnPage = + sortedParticipantsHopingToFitOnPage.filter( + participant => + !leastPrioritizedParticipantIds.has(participant.demuxId) + ); + + nextPageInSortedOrder = getNextPage({ + participants: sortedParticipantsHopingToFitOnPage, + leaveRoomForPrevPageButton: pages.length > 0, + leaveRoomForNextPageButton: pages.length + 1 < maxPages, + ...pageLayoutProps, + }); + + // Are we able to fill all of them now? Great, let's ship it. + if ( + nextPageInSortedOrder.numParticipants === + sortedParticipantsHopingToFitOnPage.length + ) { + nextPage = nextPageInSortedOrder; + break; + } + attemptNumber += 1; + } + + if (!nextPage) { + log.warn( + `GroupCallRemoteParticipants: failed after ${attemptNumber} attempts to layout + the page; pageIndex: ${pages.length}, \ + # fit in priority order: ${nextPageInPriorityOrder.numParticipants}, \ + # fit in sorted order: ${nextPageInSortedOrder.numParticipants}` + ); + nextPage = nextPageInSortedOrder; + } + } + + if (!nextPage) { + break; + } + + const nextPageTiles = + nextPage as ParticipantsInPageType; + + // Add a previous page tile if needed + if (pages.length > 0) { + nextPageTiles.rows[0].unshift({ + isPaginationButton: true, + paginationButtonType: 'prev', + videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO, + numParticipants: getTotalParticipantsOnGrid(), + }); + } + + if (!nextPage.numParticipants) { + break; + } + + remainingParticipants = remainingParticipants.slice( + nextPage.numParticipants + ); + + pages.push(nextPage); + + if (pages.length === maxPages) { + break; + } + + // Add a next page tile if needed + if (remainingParticipants.length) { + nextPageTiles.rows.at(-1)?.push({ + isPaginationButton: true, + paginationButtonType: 'next', + videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO, + numParticipants: remainingParticipants.length, + }); + } + } + return pages; +} + +/** + * Attempt to fill a new page with as many participants as will fit, leaving room for + * next/prev page buttons as needed. Participants will be added in the order provided. + * + * @returns ParticipantsInPageType, representing the participants that fit on this page + * assuming they are rendered at minimum height. Does not include prev/next buttons, + * but will leave space for them if needed. Participants are not necessarily + * returned in the row-distribution that will maximize video scaling; that should + * be done subsequently. + */ +function getNextPage({ + participants, + maxRowWidth, + minRenderedHeight, + maxRowsPerPage, + maxParticipantsPerPage, + leaveRoomForPrevPageButton, + leaveRoomForNextPageButton, + isSubsetOfAllParticipants, +}: PageLayoutPropsType & { + participants: Array; + leaveRoomForPrevPageButton: boolean; + leaveRoomForNextPageButton: boolean; + isSubsetOfAllParticipants?: boolean; +}): ParticipantsInPageType { + const paginationButtonWidth = participantWidthAtHeight( + { + videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO, + }, + minRenderedHeight + ); + let rowWidth = leaveRoomForPrevPageButton + ? paginationButtonWidth + PARTICIPANT_MARGIN + : 0; + + // Initialize fresh page with empty first row + const rows: Array> = [[]]; + let row = rows[0]; + let numParticipants = 0; + + // Start looping through participants and adding them to the rows one-by-one + for (let i = 0; i < participants.length; i += 1) { + const participant = participants[i]; + const isLastParticipant = + !isSubsetOfAllParticipants && i === participants.length - 1; + + const participantWidth = participantWidthAtHeight( + participant, + minRenderedHeight + ); + const isLastRow = rows.length === maxRowsPerPage; + const shouldShowNextButtonInThisRow = + isLastRow && !isLastParticipant && leaveRoomForNextPageButton; + + const currentRowMaxWidth = shouldShowNextButtonInThisRow + ? maxRowWidth - (paginationButtonWidth + PARTICIPANT_MARGIN) + : maxRowWidth; + + const participantFitsOnRow = + rowWidth + participantWidth + (row.length ? PARTICIPANT_MARGIN : 0) <= + currentRowMaxWidth; + + if (participantFitsOnRow) { + rowWidth += participantWidth + (row.length ? PARTICIPANT_MARGIN : 0); + row.push(participant); + numParticipants += 1; + + if (numParticipants === maxParticipantsPerPage) { + return { rows, numParticipants }; + } + } else { + if (isLastRow) { + return { rows, numParticipants }; + } + + // Start a new row! + row = [participant]; + rows.push(row); + numParticipants += 1; + rowWidth = participantWidth; + } + } + return { + rows, + numParticipants, + }; +} + +/** + * Given an arrangement of participants in rows that we know fits on a page at minimum + * rendered height, try to find an arrangement that maximizes video size, or return the + * provided arrangement with maximal video size. The result of this is ready to be + * laid out on the screen. + * + * @returns GridArrangement: { + * rows: participants in rows, + * scalar: the scalar by which can scale every video on the page and still fit + * } + */ +function arrangeParticipantsInGrid({ + participantsInRows, + maxRowWidth, + minRenderedHeight, + maxRowsPerPage, + maxGridHeight, +}: { + participantsInRows: Array>; + maxRowWidth: number; + minRenderedHeight: number; + maxRowsPerPage: number; + maxGridHeight: number; +}): GridArrangement { + // Start out with the arrangement that was prepared by getGridParticipantsByPage. + // We know this arrangement (added one-by-one) fits, so its scalar is + // guaranteed to be >= 1. Our chunking strategy below might not arrive at such + // an arrangement. + let bestArrangement: GridArrangement = { + scalar: getMaximumScaleForRows({ + maxRowWidth, + minRenderedHeight, + rows: participantsInRows, + maxGridHeight, + }), + rows: participantsInRows, + }; + + const participants = participantsInRows.flat(); + + // For each possible number of rows (starting at 0 and ending at `maxRowCount`), + // distribute participants across the rows at the minimum height. Then find the + // "scalar": how much can we scale these boxes up while still fitting them on the + // screen? The biggest scalar wins as the "best arrangement". + for (let rowCount = 1; rowCount <= maxRowsPerPage; rowCount += 1) { + // We do something pretty naïve here and chunk the grid's participants into rows. + // For example, if there were 12 grid participants and `rowCount === 3`, there + // would be 4 participants per row. + // + // This naïve chunking is suboptimal in terms of absolute best fit, but it is much + // faster and simpler than trying to do this perfectly. In practice, this works + // fine in the UI from our testing. + const numberOfParticipantsInRow = Math.ceil(participants.length / rowCount); + const rows = chunk(participants, numberOfParticipantsInRow); + + const scalar = getMaximumScaleForRows({ + maxRowWidth, + minRenderedHeight, + rows, + maxGridHeight, + }); + + if (scalar > bestArrangement.scalar) { + bestArrangement = { + scalar, + rows, + }; + } + } + + return bestArrangement; +} + +// We need to find the scalar for this arrangement. Imagine that we have these +// participants at the minimum heights, and we want to scale everything up until +// it's about to overflow. +function getMaximumScaleForRows({ + maxRowWidth, + minRenderedHeight, + maxGridHeight, + rows, +}: { + maxRowWidth: number; + minRenderedHeight: number; + maxGridHeight: number; + rows: Array>; +}): number { + if (!rows.length) { + return 0; + } + const widestRow = maxBy(rows, x => + totalRowWidthAtHeight(x, minRenderedHeight) + ); + + strictAssert(widestRow, 'Could not find widestRow'); + + // We don't want it to overflow horizontally or vertically, so we calculate a + // "width scalar" and "height scalar" and choose the smaller of the two. (Choosing + // the LARGER of the two could cause overflow.) + const widthScalar = + (maxRowWidth - (widestRow.length - 1) * PARTICIPANT_MARGIN) / + totalRowWidthAtHeight(widestRow, minRenderedHeight); + + const heightScalar = + (maxGridHeight - (rows.length - 1) * PARTICIPANT_MARGIN) / + (rows.length * minRenderedHeight); + + return Math.min(widthScalar, heightScalar); +} + +function isGroupCallRemoteParticipant( + tile: ParticipantTileType +): tile is GroupCallRemoteParticipantType { + return 'demuxId' in tile; +} + +function isPaginationButton( + tile: ParticipantTileType +): tile is PaginationButtonType { + return 'isPaginationButton' in tile; +} diff --git a/ts/hooks/useActivateSpeakerViewOnPresenting.ts b/ts/hooks/useActivateSpeakerViewOnPresenting.ts index 78bae138e7..5ba8ffff1c 100644 --- a/ts/hooks/useActivateSpeakerViewOnPresenting.ts +++ b/ts/hooks/useActivateSpeakerViewOnPresenting.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import type { AciString } from '../types/ServiceId'; import { usePrevious } from './usePrevious'; @@ -12,6 +12,15 @@ type RemoteParticipant = { aci?: AciString; }; +export function usePresenter( + remoteParticipants: ReadonlyArray +): AciString | undefined { + return useMemo( + () => remoteParticipants.find(participant => participant.presenting)?.aci, + [remoteParticipants] + ); +} + export function useActivateSpeakerViewOnPresenting({ remoteParticipants, switchToPresentationView, @@ -21,9 +30,7 @@ export function useActivateSpeakerViewOnPresenting({ switchToPresentationView: () => void; switchFromPresentationView: () => void; }): void { - const presenterAci = remoteParticipants.find( - participant => participant.presenting - )?.aci; + const presenterAci = usePresenter(remoteParticipants); const prevPresenterAci = usePrevious(presenterAci, presenterAci); useEffect(() => { diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index d0ca15cfdd..e0b0aba9cd 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -121,6 +121,7 @@ export type ActiveCallStateType = { hasLocalVideo: boolean; localAudioLevel: number; viewMode: CallViewMode; + viewModeBeforePresentation?: CallViewMode; joinedAt: number | null; outgoingRing: boolean; pip: boolean; @@ -413,6 +414,7 @@ const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; const CANCEL_CALL = 'calling/CANCEL_CALL'; const CANCEL_INCOMING_GROUP_CALL_RING = 'calling/CANCEL_INCOMING_GROUP_CALL_RING'; +const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW'; const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY'; const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED'; @@ -442,7 +444,6 @@ 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'; const SWITCH_TO_PRESENTATION_VIEW = 'calling/SWITCH_TO_PRESENTATION_VIEW'; const SWITCH_FROM_PRESENTATION_VIEW = 'calling/SWITCH_FROM_PRESENTATION_VIEW'; @@ -611,8 +612,9 @@ type ToggleSettingsActionType = ReadonlyDeep<{ type: 'calling/TOGGLE_SETTINGS'; }>; -type ToggleSpeakerViewActionType = ReadonlyDeep<{ - type: 'calling/TOGGLE_SPEAKER_VIEW'; +type ChangeCallViewActionType = ReadonlyDeep<{ + type: 'calling/CHANGE_CALL_VIEW'; + viewMode: CallViewMode; }>; type SwitchToPresentationViewActionType = ReadonlyDeep<{ @@ -628,6 +630,7 @@ export type CallingActionType = | AcceptCallPendingActionType | CancelCallActionType | CancelIncomingGroupCallRingActionType + | ChangeCallViewActionType | StartCallingLobbyActionType | CallStateChangeFulfilledActionType | ChangeIODeviceFulfilledActionType @@ -658,7 +661,6 @@ export type CallingActionType = | TogglePipActionType | SetPresentingFulfilledActionType | ToggleSettingsActionType - | ToggleSpeakerViewActionType | SwitchToPresentationViewActionType | SwitchFromPresentationViewActionType; @@ -1474,9 +1476,10 @@ function toggleSettings(): ToggleSettingsActionType { }; } -function toggleSpeakerView(): ToggleSpeakerViewActionType { +function changeCallView(mode: CallViewMode): ChangeCallViewActionType { return { - type: TOGGLE_SPEAKER_VIEW, + type: CHANGE_CALL_VIEW, + viewMode: mode, }; } @@ -1491,12 +1494,12 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType { type: SWITCH_FROM_PRESENTATION_VIEW, }; } - export const actions = { acceptCall, callStateChange, cancelCall, cancelIncomingGroupCallRing, + changeCallView, changeIODevice, closeNeedPermissionScreen, declineCall, @@ -1524,9 +1527,9 @@ export const actions = { setLocalAudio, setLocalPreview, setLocalVideo, + setOutgoingRing, setPresenting, setRendererCanvas, - setOutgoingRing, startCall, startCallingLobby, switchToPresentationView, @@ -1535,7 +1538,6 @@ export const actions = { togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, - toggleSpeakerView, }; export const useCallingActions = (): BoundActionCreatorsMapObject< @@ -1643,7 +1645,7 @@ export function reducer( hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, pip: false, safetyNumberChangedAcis: [], settingsDialogOpen: false, @@ -1672,7 +1674,7 @@ export function reducer( hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, pip: false, safetyNumberChangedAcis: [], settingsDialogOpen: false, @@ -1696,7 +1698,7 @@ export function reducer( hasLocalAudio: true, hasLocalVideo: action.payload.asVideoCall, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, pip: false, safetyNumberChangedAcis: [], settingsDialogOpen: false, @@ -1851,7 +1853,7 @@ export function reducer( hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, pip: false, safetyNumberChangedAcis: [], settingsDialogOpen: false, @@ -2312,26 +2314,26 @@ export function reducer( }; } - if (action.type === TOGGLE_SPEAKER_VIEW) { + if (action.type === CHANGE_CALL_VIEW) { const { activeCallState } = state; if (!activeCallState) { - log.warn('Cannot toggle speaker view when there is no active call'); + log.warn('Cannot change call view when there is no active call'); return state; } - let newViewMode: CallViewMode; - if (activeCallState.viewMode === CallViewMode.Grid) { - newViewMode = CallViewMode.Speaker; - } else { - // This will switch presentation/speaker to grid - newViewMode = CallViewMode.Grid; + if (activeCallState.viewMode === action.viewMode) { + return state; } return { ...state, activeCallState: { ...activeCallState, - viewMode: newViewMode, + viewMode: action.viewMode, + viewModeBeforePresentation: + action.viewMode === CallViewMode.Presentation + ? activeCallState.viewMode + : undefined, }, }; } @@ -2343,9 +2345,7 @@ export function reducer( return state; } - // "Presentation" mode reverts to "Grid" when the call is over so don't - // switch it if it is in "Speaker" mode. - if (activeCallState.viewMode === CallViewMode.Speaker) { + if (activeCallState.viewMode === CallViewMode.Presentation) { return state; } @@ -2354,6 +2354,7 @@ export function reducer( activeCallState: { ...activeCallState, viewMode: CallViewMode.Presentation, + viewModeBeforePresentation: activeCallState.viewMode, }, }; } @@ -2373,7 +2374,8 @@ export function reducer( ...state, activeCallState: { ...activeCallState, - viewMode: CallViewMode.Grid, + viewMode: + activeCallState.viewModeBeforePresentation ?? CallViewMode.Paginated, }, }; } diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 04ada098ad..f0745e5749 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -5,7 +5,6 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; import type { - ActiveCallStateType, CallingStateType, CallsByConversationType, DirectCallStateType, @@ -14,7 +13,6 @@ import type { import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; import { getUserACI } from './user'; import { getOwn } from '../../util/getOwn'; -import { CallViewMode } from '../../types/Calling'; import type { AciString } from '../../types/ServiceId'; export type CallStateType = DirectCallStateType | GroupCallStateType; @@ -85,12 +83,3 @@ export const areAnyCallsActiveOrRinging = createSelector( getIncomingCall, (activeCall, incomingCall): boolean => Boolean(activeCall || incomingCall) ); - -export const isInSpeakerView = ( - call: Pick | undefined -): boolean => { - return Boolean( - call?.viewMode === CallViewMode.Presentation || - call?.viewMode === CallViewMode.Speaker - ); -}; diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 40423ebff3..d20448974d 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -147,6 +147,7 @@ const mapStateToActiveCallProp = ( hasLocalVideo: activeCallState.hasLocalVideo, localAudioLevel: activeCallState.localAudioLevel, viewMode: activeCallState.viewMode, + viewModeBeforePresentation: activeCallState.viewModeBeforePresentation, joinedAt: activeCallState.joinedAt, outgoingRing: activeCallState.outgoingRing, pip: activeCallState.pip, diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 788d5d485f..873e27baa3 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -64,7 +64,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: true, @@ -144,7 +144,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: false, @@ -154,23 +154,6 @@ describe('calling duck', () => { }, }; - const stateWithActivePresentationViewGroupCall: CallingStateTypeWithActiveCall = - { - ...stateWithGroupCall, - activeCallState: { - ...stateWithActiveGroupCall.activeCallState, - viewMode: CallViewMode.Presentation, - }, - }; - - const stateWithActiveSpeakerViewGroupCall: CallingStateTypeWithActiveCall = { - ...stateWithGroupCall, - activeCallState: { - ...stateWithActiveGroupCall.activeCallState, - viewMode: CallViewMode.Speaker, - }, - }; - const ourAci = generateAci(); const getEmptyRootState = () => { @@ -476,7 +459,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: true, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: false, @@ -570,7 +553,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: true, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: false, @@ -1163,7 +1146,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: false, @@ -1695,7 +1678,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: true, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], pip: false, @@ -1982,7 +1965,7 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], pip: false, @@ -2056,58 +2039,14 @@ describe('calling duck', () => { }); }); - describe('toggleSpeakerView', () => { - const { toggleSpeakerView } = actions; - - it('toggles speaker view from grid view', () => { - const afterOneToggle = reducer( - stateWithActiveGroupCall, - toggleSpeakerView() - ); - const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView()); - const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView()); - - assert.strictEqual( - afterOneToggle.activeCallState?.viewMode, - CallViewMode.Speaker - ); - assert.strictEqual( - afterTwoToggles.activeCallState?.viewMode, - CallViewMode.Grid - ); - assert.strictEqual( - afterThreeToggles.activeCallState?.viewMode, - CallViewMode.Speaker - ); - }); - - it('toggles speaker view from presentation view', () => { - const afterOneToggle = reducer( - stateWithActivePresentationViewGroupCall, - toggleSpeakerView() - ); - const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView()); - const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView()); - - assert.strictEqual( - afterOneToggle.activeCallState?.viewMode, - CallViewMode.Grid - ); - assert.strictEqual( - afterTwoToggles.activeCallState?.viewMode, - CallViewMode.Speaker - ); - assert.strictEqual( - afterThreeToggles.activeCallState?.viewMode, - CallViewMode.Grid - ); - }); - }); - describe('switchToPresentationView', () => { - const { switchToPresentationView, switchFromPresentationView } = actions; + const { + switchToPresentationView, + switchFromPresentationView, + changeCallView, + } = actions; - it('toggles presentation view from grid view', () => { + it('toggles presentation view from paginated view', () => { const afterOneToggle = reducer( stateWithActiveGroupCall, switchToPresentationView() @@ -2116,7 +2055,7 @@ describe('calling duck', () => { afterOneToggle, switchToPresentationView() ); - const finalState = reducer( + const afterThreeToggles = reducer( afterOneToggle, switchFromPresentationView() ); @@ -2130,28 +2069,28 @@ describe('calling duck', () => { CallViewMode.Presentation ); assert.strictEqual( - finalState.activeCallState?.viewMode, - CallViewMode.Grid + afterThreeToggles.activeCallState?.viewMode, + CallViewMode.Paginated ); }); - it('does not toggle presentation view from speaker view', () => { - const afterOneToggle = reducer( - stateWithActiveSpeakerViewGroupCall, + it('switches to previously selected view after presentation', () => { + const stateOverflow = reducer( + stateWithActiveGroupCall, + changeCallView(CallViewMode.Overflow) + ); + const statePresentation = reducer( + stateOverflow, switchToPresentationView() ); - const finalState = reducer( - afterOneToggle, + const stateAfterPresentation = reducer( + statePresentation, switchFromPresentationView() ); assert.strictEqual( - afterOneToggle.activeCallState?.viewMode, - CallViewMode.Speaker - ); - assert.strictEqual( - finalState.activeCallState?.viewMode, - CallViewMode.Speaker + stateAfterPresentation.activeCallState?.viewMode, + CallViewMode.Overflow ); }); }); diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 6948fa4f75..865a986ea6 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -66,7 +66,7 @@ describe('state/selectors/calling', () => { hasLocalAudio: true, hasLocalVideo: false, localAudioLevel: 0, - viewMode: CallViewMode.Grid, + viewMode: CallViewMode.Paginated, showParticipantsList: false, safetyNumberChangedAcis: [], outgoingRing: true, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 095b2a8aea..1ff1c5eeba 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -11,10 +11,12 @@ export enum CallMode { Group = 'Group', } -// Speaker and Presentation has the same UI, but Presentation mode will switch -// to Grid mode when the presentation is over. +// Speaker and Presentation mode have the same UI, but Presentation is only set +// automatically when someone starts to present, and will revert to the previous view mode +// once presentation is complete export enum CallViewMode { - Grid = 'Grid', + Paginated = 'Paginated', + Overflow = 'Overflow', Speaker = 'Speaker', Presentation = 'Presentation', } @@ -38,6 +40,7 @@ export type ActiveCallBaseType = { hasLocalVideo: boolean; localAudioLevel: number; viewMode: CallViewMode; + viewModeBeforePresentation?: CallViewMode; isSharingScreen?: boolean; joinedAt: number | null; outgoingRing: boolean;