From 0eb697fa82bde75e3cbfd4bc0df7a389df71f762 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 11 Mar 2025 08:30:55 +1000 Subject: [PATCH] Calling: New option to expand your local preview --- _locales/en/messages.json | 12 ++ images/icons/v3/maximize/maximize.svg | 1 + images/icons/v3/minimize/minimize.svg | 1 + stylesheets/_modules.scss | 93 ++++++---- stylesheets/_variables.scss | 3 +- stylesheets/components/CallControls.scss | 2 +- .../components/CallingAudioIndicator.scss | 6 +- stylesheets/components/CallingButton.scss | 36 ++++ .../components/CallingStatusIndicator.scss | 17 +- ts/components/CallManager.stories.tsx | 3 + ts/components/CallManager.tsx | 5 + ts/components/CallScreen.stories.tsx | 68 ++++++- ts/components/CallScreen.tsx | 168 ++++++++++++++---- ts/components/CallingButton.tsx | 21 ++- ts/components/CallingPip.stories.tsx | 1 + ts/components/ShortcutGuide.tsx | 9 +- ts/state/ducks/calling.ts | 35 +++- ts/state/smart/CallManager.tsx | 3 + ts/test-electron/state/ducks/calling_test.ts | 11 ++ .../state/selectors/calling_test.ts | 1 + ts/types/Calling.ts | 1 + 21 files changed, 400 insertions(+), 97 deletions(-) create mode 100644 images/icons/v3/maximize/maximize.svg create mode 100644 images/icons/v3/minimize/minimize.svg diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cdaec4c22..583975e54 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3555,6 +3555,10 @@ "messageformat": "Toggle video on and off", "description": "Shown in the shortcuts guide" }, + "icu:Keyboard--toggle-preview": { + "messageformat": "Toggle expanded preview on and off", + "description": "Shown in the shortcuts guide" + }, "icu:Keyboard--accept-video-call": { "messageformat": "Answer call with video (video calls only)", "description": "Shown in the calling keyboard shortcuts guide" @@ -4115,6 +4119,14 @@ "messageformat": "Fullscreen call", "description": "Title for picture-in-picture toggle" }, + "icu:calling__preview--maximize": { + "messageformat": "Maximize preview", + "description": "Title for button to make in-call video preview bigger" + }, + "icu:calling__preview--minimize": { + "messageformat": "Minimize preview", + "description": "Title for button to make in-call video preview smaller" + }, "icu:calling__change-view": { "messageformat": "Change view", "description": "Tooltip for changing the in-call layout of remote participants in a group call" diff --git a/images/icons/v3/maximize/maximize.svg b/images/icons/v3/maximize/maximize.svg new file mode 100644 index 000000000..5a5acd3f6 --- /dev/null +++ b/images/icons/v3/maximize/maximize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/minimize/minimize.svg b/images/icons/v3/minimize/minimize.svg new file mode 100644 index 000000000..50a44ab0a --- /dev/null +++ b/images/icons/v3/minimize/minimize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 93c91de6e..786de91a0 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3996,8 +3996,6 @@ button.module-image__border-overlay:focus { } } .module-ongoing-call { - $local-preview-height: 80px; - &__remote-video-enabled { background-color: variables.$color-gray-95; height: 100%; @@ -4075,6 +4073,13 @@ button.module-image__border-overlay:focus { position: absolute; inset-inline-end: 16px; bottom: 112px; + transition: bottom 0.3s variables.$ease-out-local-preview; + } + &__direct-call-speaking-indicator--self-view-expanded { + bottom: 330px; + } + &__direct-call-speaking-indicator--expanded-no-controls { + bottom: 232px; } &__participants { @@ -4523,6 +4528,56 @@ button.module-image__border-overlay:focus { } } + &__local-preview { + z-index: variables.$z-index-calling-pip; + border-radius: 12px; + position: absolute; + + display: flex; + height: 80px; + width: variables.$calling-local-preview-normal-width; + + inset-inline-end: 16px; + bottom: 16px; + + transition: all 0.3s variables.$ease-out-local-preview; + + overflow: hidden; + cursor: pointer; + + &--active { + box-shadow: 0px 4px 14px 0px variables.$color-black-alpha-40; + } + + &--expanded { + bottom: 112px; + height: 200px; + width: 312px; + } + + &--controls-hidden { + bottom: 16px; + } + + &__video { + height: 100%; + width: 100%; + + video { + // The background-color is seen while the video loads. + background-color: variables.$color-gray-75; + height: 100%; + width: 100%; + + transform: rotateY(180deg); + } + + &--presenting video { + transform: inherit; + } + } + } + &__footer { bottom: 0; display: flex; @@ -4537,40 +4592,6 @@ button.module-image__border-overlay:focus { flex-grow: 1; justify-content: center; } - - &__local-preview { - border-radius: 10px; - display: flex; - flex-shrink: 0; - height: $local-preview-height; - margin-block-end: 16px; - margin-inline: 0 16px; - overflow: hidden; - position: relative; - width: variables.$calling-local-preview-width; - - &--active { - box-shadow: 0px 4px 14px 0px variables.$color-black-alpha-40; - } - - &__video { - height: 100%; - width: 100%; - - video { - // The background-color is seen while the video loads. - background-color: variables.$color-gray-75; - height: 100%; - width: 100%; - - transform: rotateY(180deg); - } - - &--presenting video { - transform: inherit; - } - } - } } &__controls { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index ce60757c5..abefff57a 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -272,11 +272,12 @@ $color-selected-message-background-dark: $color-gray-65; $header-height: 52px; $ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); +$ease-out-local-preview: cubic-bezier(0.17, 0.17, 0, 1); $calling-background-color: $color-gray-90; // Maintain aspect ratio 960x720 with $local-preview-height -$calling-local-preview-width: 106.67px; +$calling-local-preview-normal-width: 106.67px; // General diff --git a/stylesheets/components/CallControls.scss b/stylesheets/components/CallControls.scss index a2c1db5e5..f6ed49ae2 100644 --- a/stylesheets/components/CallControls.scss +++ b/stylesheets/components/CallControls.scss @@ -118,7 +118,7 @@ } .CallControls__OuterSpacer { - flex-basis: calc(variables.$calling-local-preview-width + 16px); + flex-basis: calc(variables.$calling-local-preview-normal-width + 16px); } .CallControls__ReactionPickerContainer { diff --git a/stylesheets/components/CallingAudioIndicator.scss b/stylesheets/components/CallingAudioIndicator.scss index 7f903cd01..d5447ad91 100644 --- a/stylesheets/components/CallingAudioIndicator.scss +++ b/stylesheets/components/CallingAudioIndicator.scss @@ -38,10 +38,10 @@ } } -.module-ongoing-call__footer__local-preview .CallingAudioIndicator { +.module-ongoing-call__local-preview .CallingAudioIndicator { position: absolute; - top: 6px; - inset-inline-end: 6px; + top: 8px; + inset-inline-end: 8px; z-index: variables.$z-index-base; } diff --git a/stylesheets/components/CallingButton.scss b/stylesheets/components/CallingButton.scss index 31b4ac50b..f106cb6f8 100644 --- a/stylesheets/components/CallingButton.scss +++ b/stylesheets/components/CallingButton.scss @@ -160,6 +160,22 @@ &--more-options { @include calling-button-icon-regular('../images/icons/v3/more/more.svg'); } + + &--maximize { + @include calling-button-icon( + '../images/icons/v3/maximize/maximize.svg', + rgba(variables.$color-gray-80, 0.7), + variables.$color-white + ); + } + + &--minimize { + @include calling-button-icon( + '../images/icons/v3/minimize/minimize.svg', + rgba(variables.$color-gray-80, 0.7), + variables.$color-white + ); + } } &__button-container { @@ -228,3 +244,23 @@ border-top-color: variables.$color-gray-80 !important; } } + +.CallingButton__Button--self-view { + position: absolute; + inset-inline-start: 8px; + top: 8px; + + .CallingButton__button-container { + margin: 0; + } +} + +.CallingButton__Button--self-view-normal .CallingButton__icon { + height: 28px; + width: 28px; + + div { + height: 16px; + width: 16px; + } +} diff --git a/stylesheets/components/CallingStatusIndicator.scss b/stylesheets/components/CallingStatusIndicator.scss index adbe1d2ab..c0b70fd2d 100644 --- a/stylesheets/components/CallingStatusIndicator.scss +++ b/stylesheets/components/CallingStatusIndicator.scss @@ -36,27 +36,26 @@ ); } -.CallingStatusIndicator--Video::after { +.CallingStatusIndicator--NoVideo::after { @include mixins.color-svg( '../images/icons/v3/video/video-slash-fill-light.svg', variables.$color-white ); } -.module-ongoing-call__footer__local-preview .CallingStatusIndicator { +.module-ongoing-call__local-preview .CallingStatusIndicator { position: absolute; z-index: variables.$z-index-base; } -.module-ongoing-call__footer__local-preview .CallingStatusIndicator--Video { - top: 6px; - inset-inline-start: 6px; +.module-ongoing-call__local-preview .CallingStatusIndicator--NoVideo { + top: 8px; + inset-inline-start: 8px; } -.module-ongoing-call__footer__local-preview - .CallingStatusIndicator--HandRaised { - bottom: 6px; - inset-inline-start: 6px; +.module-ongoing-call__local-preview .CallingStatusIndicator--HandRaised { + bottom: 8px; + inset-inline-start: 8px; } .module-ongoing-call__participants__grid diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 6b098a7f4..37b402aed 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -81,6 +81,7 @@ const getCommonActiveCallData = () => ({ viewMode: CallViewMode.Paginated, outgoingRing: true, pip: false, + selfViewExpanded: false, settingsDialogOpen: false, showParticipantsList: false, }); @@ -147,6 +148,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ toggleScreenRecordingPermissionsDialog: action( 'toggle-screen-recording-permissions-dialog' ), + toggleSelfViewExpanded: action('toggle-self-view-expanded'), toggleSettings: action('toggle-settings'), pauseVoiceNotePlayer: action('pause-audio-player'), }); @@ -181,6 +183,7 @@ const getActiveCallForCallLink = ( pendingParticipants: overrideProps.pendingParticipants ?? [], raisedHands: new Set(), remoteAudioLevels: new Map(), + selfViewExpanded: false, suggestLowerHand: false, }; }; diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 56c10a5c4..b0c321067 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -138,6 +138,7 @@ export type PropsType = { togglePip: () => void; toggleCallLinkPendingParticipantModal: (contactId: string) => void; toggleScreenRecordingPermissionsDialog: () => unknown; + toggleSelfViewExpanded: () => unknown; toggleSettings: () => void; pauseVoiceNotePlayer: () => void; } & Pick; @@ -200,6 +201,7 @@ function ActiveCallManager({ toggleParticipants, togglePip, toggleScreenRecordingPermissionsDialog, + toggleSelfViewExpanded, toggleSettings, pauseVoiceNotePlayer, }: ActiveCallManagerPropsType): JSX.Element { @@ -480,6 +482,7 @@ function ActiveCallManager({ } toggleParticipants={toggleParticipants} togglePip={togglePip} + toggleSelfViewExpanded={toggleSelfViewExpanded} toggleSettings={toggleSettings} /> {presentingSourcesAvailable && presentingSourcesAvailable.length ? ( @@ -573,6 +576,7 @@ export function CallManager({ togglePip, toggleCallLinkPendingParticipantModal, toggleScreenRecordingPermissionsDialog, + toggleSelfViewExpanded, toggleSettings, }: PropsType): JSX.Element | null { const isCallActive = Boolean(activeCall); @@ -667,6 +671,7 @@ export function CallManager({ toggleScreenRecordingPermissionsDialog={ toggleScreenRecordingPermissionsDialog } + toggleSelfViewExpanded={toggleSelfViewExpanded} toggleSettings={toggleSettings} /> diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 692ee6e0d..120c9b7f8 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -56,6 +56,7 @@ type OverridePropsBase = { hasLocalVideo?: boolean; localAudioLevel?: number; viewMode?: CallViewMode; + outgoingRing?: boolean; reactions?: ActiveCallReactionsType; }; @@ -63,6 +64,9 @@ type DirectCallOverrideProps = OverridePropsBase & { callMode: CallMode.Direct; callState?: CallState; hasRemoteVideo?: boolean; + outgoingRing?: boolean; + selfViewExpanded?: boolean; + remoteAudioLevel?: number; }; type GroupCallOverrideProps = OverridePropsBase & { @@ -72,9 +76,11 @@ type GroupCallOverrideProps = OverridePropsBase & { peekedParticipants?: Array; pendingParticipants?: Array; raisedHands?: Set; - remoteParticipants?: Array; remoteAudioLevel?: number; + remoteParticipants?: Array; + selfViewExpanded?: boolean; suggestLowerHand?: boolean; + outgoingRing?: boolean; }; const createActiveDirectCallProp = ( @@ -84,7 +90,7 @@ const createActiveDirectCallProp = ( conversation, callState: overrideProps.callState ?? CallState.Accepted, peekedParticipants: [] as [], - remoteAudioLevel: 0, + remoteAudioLevel: overrideProps.remoteAudioLevel ?? 0, remoteParticipants: [ { hasRemoteVideo: overrideProps.hasRemoteVideo ?? false, @@ -168,9 +174,10 @@ const createActiveCallProp = ( hasLocalVideo: overrideProps.hasLocalVideo ?? false, localAudioLevel: overrideProps.localAudioLevel ?? 0, viewMode: overrideProps.viewMode ?? CallViewMode.Sidebar, - outgoingRing: true, + outgoingRing: overrideProps.outgoingRing ?? true, pip: false, settingsDialogOpen: false, + selfViewExpanded: overrideProps.selfViewExpanded ?? false, showParticipantsList: false, }; @@ -236,6 +243,7 @@ const createProps = ( toggleScreenRecordingPermissionsDialog: action( 'toggle-screen-recording-permissions-dialog' ), + toggleSelfViewExpanded: action('toggle-self-view-expanded'), toggleSettings: action('toggle-settings'), }); @@ -269,7 +277,7 @@ export function PreRing(): JSX.Element { ); } -export function Ringing(): JSX.Element { +export function DirectRinging(): JSX.Element { return ( + ); +} + +export function SelfViewExpanded(): JSX.Element { + return ( + + ); +} + +export function SelfViewExpandedBothSpeaking(): JSX.Element { + return ( + + ); +} + export function GroupCall1(): JSX.Element { return ( ; +} + export function GroupCallYourHandRaised(): JSX.Element { return ( void; togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; + toggleSelfViewExpanded: () => void; toggleSettings: () => void; changeCallView: (mode: CallViewMode) => void; } & Pick; @@ -215,6 +216,7 @@ export function CallScreen({ toggleParticipants, togglePip, toggleScreenRecordingPermissionsDialog, + toggleSelfViewExpanded, toggleSettings, }: PropsType): JSX.Element { const { @@ -280,7 +282,6 @@ export function CallScreen({ }, []); const [controlsHover, setControlsHover] = useState(false); - const onControlsMouseEnter = useCallback(() => { setControlsHover(true); }, [setControlsHover]); @@ -290,7 +291,6 @@ export function CallScreen({ }, [setControlsHover]); const [showControls, setShowControls] = useState(true); - useEffect(() => { if ( !showControls || @@ -306,6 +306,28 @@ export function CallScreen({ return clearTimeout.bind(null, timer); }, [showControls, showReactionPicker, stickyControls, controlsHover]); + const [selfViewHover, setSelfViewHover] = useState(false); + const onSelfViewMouseEnter = useCallback(() => { + setSelfViewHover(true); + }, [setSelfViewHover]); + + const onSelfViewMouseLeave = useCallback(() => { + setSelfViewHover(false); + }, [setSelfViewHover]); + + const [showSelfViewControls, setShowSelfViewControls] = useState(false); + useEffect(() => { + if (selfViewHover) { + setShowSelfViewControls(true); + return; + } + + const timer = setTimeout(() => { + setShowSelfViewControls(false); + }, 2000); + return clearTimeout.bind(null, timer); + }, [showSelfViewControls, setShowSelfViewControls, selfViewHover]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { let eventHandled = false; @@ -314,16 +336,20 @@ export function CallScreen({ if (event.shiftKey && (key === 'V' || key === 'v')) { toggleVideo(); + setShowControls(true); eventHandled = true; } else if (event.shiftKey && (key === 'M' || key === 'm')) { toggleAudio(); + setShowControls(true); + eventHandled = true; + } else if (event.shiftKey && (key === 'P' || key === 'p')) { + toggleSelfViewExpanded(); eventHandled = true; } if (eventHandled) { event.preventDefault(); event.stopPropagation(); - setShowControls(true); } }; @@ -331,7 +357,7 @@ export function CallScreen({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [toggleAudio, toggleVideo]); + }, [setShowControls, toggleAudio, toggleSelfViewExpanded, toggleVideo]); useEffect(() => { if (!showReactionPicker) { @@ -411,7 +437,24 @@ export function CallScreen({ let lonelyInCallNode: ReactNode; let localPreviewNode: ReactNode; + const raisedHands = isGroupOrAdhocActiveCall(activeCall) + ? activeCall.raisedHands + : undefined; + + // This is the value of our hand raised as seen by remote clients. We should prefer + // to use it in UI so the user understands what remote clients see. + const syncedLocalHandRaised = isHandRaised(raisedHands, localDemuxId); + const isLonelyInCall = !activeCall.remoteParticipants.length; + const handlePreviewClick = useCallback( + (event?: React.MouseEvent) => { + event?.preventDefault(); + event?.stopPropagation(); + + toggleSelfViewExpanded(); + }, + [toggleSelfViewExpanded] + ); if (isLonelyInCall) { lonelyInCallNode = ( @@ -438,12 +481,12 @@ export function CallScreen({ ); } else { - localPreviewNode = isSendingVideo ? ( + const innerPreviewNode = isSendingVideo ? (
@@ -467,6 +510,74 @@ export function CallScreen({ /> ); + localPreviewNode = ( + // Keyboard shortcuts are available for this gesture, no need for keyboard support + /* eslint-disable-next-line max-len */ + /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ +
+ {innerPreviewNode} + {!isSendingVideo && ( +
+ )} + +
+ +
+ {syncedLocalHandRaised && ( +
+ )} +
+ ); } let videoButtonType: CallingButtonType; @@ -503,14 +614,6 @@ export function CallScreen({ presentingButtonType = CallingButtonType.PRESENTING_OFF; } - const raisedHands = isGroupOrAdhocActiveCall(activeCall) - ? activeCall.raisedHands - : undefined; - - // This is the value of our hand raised as seen by remote clients. We should prefer - // to use it in UI so the user understands what remote clients see. - const syncedLocalHandRaised = isHandRaised(raisedHands, localDemuxId); - // Don't call setLocalHandRaised because it only sets local state. Instead call // toggleRaiseHand() which will set ringrtc state and call setLocalHandRaised. const [localHandRaised, setLocalHandRaised] = useState( @@ -877,7 +980,17 @@ export function CallScreen({ /> ) : null} {activeCall.callMode === CallMode.Direct && ( -
+
)} - {/* We render the local preview first and set the footer flex direction to row-reverse - to ensure the preview is visible at low viewport widths. */} + {localPreviewNode} + {/* We set flex direction to row-reverse to render outward from local preview */}
- {localPreviewNode ? ( -
- {localPreviewNode} - {!isSendingVideo && ( -
- )} - - {syncedLocalHandRaised && ( -
- )} -
- ) : ( -
- )} +
{ + event.preventDefault(); + event.stopPropagation(); + + onClick(); + }, + [onClick] + ); const buttonContent = (